本規范描述的是一種抽象化的虛擬機的行為,而不是任何一種(譯者注:包括 Oracle 公司自己的 HotSpot 和 JRockit 虛擬機)被廣泛使用的虛擬機實現。
記住:JVM規范是一種高度抽象行為的描述,而不是具體虛擬機的實現。
所有在虛擬機規范之中沒有明確描述的實現細節,都不應成為虛擬機設計者發揮創造性的牽絆,設計者可以完全自主決定所有規范中不曾描述的虛擬機內部細節,例如:運行時數據區的內存如何布局、選用哪種垃圾收集的算法、是否要對虛擬機字節碼指令進行一些內部優化操作(如使用即時編譯器把字節碼編譯為機器碼)。
簡單地說,就是《JVM規范》中提到的內容,你一定要遵守。但沒有提到的內容,你就自由發揮。所以我們要知道,我們經常聽到的老年代、年輕代、永久代,其實只是HotSpot虛擬機的實現而已。因為《JVM規范》中並沒有規定這些東西。
編譯后被 Java 虛擬機所執行的代碼使用了一種平台中立(不依賴於特定硬件及操作系統的)的二進制格式來表示,並且經常(但並非絕對)以文件的形式存儲,因此這種格式被稱為 Class文件格式。
實際上字節碼文件,即Class文件格式使用十六進制編碼,使用二進制格式存儲。
Class 文件格式中精確地定義了類與接口的表示形式, 包括在平台相關的目標文件格式中一些細節上的慣例①,例如字節序(Byte Ordering)等。
這里又一個計算機的基礎概念:字節序。有時間可以百度一下搞清楚這個概念。當然了,這個並不會影響對於JVM的理解。可以參考這篇文章:https://www.cnblogs.com/broglie/p/5645200.html
JVM的數據類型
與 Java 程序語言中的數據類型相似,Java 虛擬機可以操作的數據類型可分為兩類:原始類型(Primitive Types,也經常翻譯為原生類型或者基本類型)和引用類型(Reference Types)。與之對應,也存在有原始值(Primitive Values)和引用值(Reference Values)兩種類型的數值。
JVM中也有兩種類型:原始類型、引用類型。
Java 虛擬機希望盡可能多的類型檢查能在程序運行之前完成,換句話說,編譯器應當在編譯
期間盡最大努力完成可能的類型檢查,使得虛擬機在運行期間無需進行這些操作。
從這句話我們知道:在編譯期間是會及逆行類型檢查的,通過在編譯進行類型檢查,減少JVM運行時的工作量,提高效率。
Java 虛擬機所支持的原始數據類型包括了數值類型(Numeric Types)、 布爾類型(Boolean Type § 2.3.4) 和 returnAddress 類型(§ 2.3.3) 三類。其中數值類型又分為整型類型(Integral Types, § 2.3.1)和浮點類型(Floating-Point Types, § 2.3.2)兩種。
和Java語言不同,JVM的原始類型包括:數值類型、布爾類型、returnAddress類型。
- 數值類型。又分為整數類型和浮點類型。整數類型包括:byet、short、int、long、char。浮點類型包括:float類型、double類型。
- 布爾類型。包括:boolean類型,取值為true和false,默認為false。
- returnAddress類型。表示一條字節碼指令的操作碼。
可以看到在所有的虛擬機支持的原始類型之中,只有 returnAddress 類型是不能直接 Java 語言的數據類型對應起來的。
整數類型以及整型值
Java 虛擬機中的整型類型的取值范圍如下:
對於 byte 類型,取值范圍是從-128 至 127(-27至 27-1),包括-128 和 127。
對於 short 類型,取值范圍是從−32768 至 32767(-215至 215-1),包括−32768 和 32767。
對於 int 類型,取值范圍是從−2147483648 至 2147483647(-231至 231-1),包括−2147483648 和 2147483647。
對於 long 類型,取值范圍是從−9223372036854775808 至 9223372036854775807(-263至 263-1),包括−9223372036854775808 和 9223372036854775807。
對於 char 類型, 取值范圍是從 0 至 65535,包括 0 和 65535。
浮點類型的取值及范圍
浮點類型包含 float 類型和 double 類型兩種,它們在概念上與《IEEE Standard for Binary Floating-Point Arithmetic》 ANSI/IEEE Std. 754-1985(IEEE, New York)標准中定義的 32 位單精度和 64 位雙精度 IEEE 754 格式取值和操作都是一致的。
浮點類型包括了 float 類型和 double 類型,它們是在 IEEE 754 標准中定義的。
所有 Java 虛擬機的實現都必須支持兩種標准的浮點數值集合:單精度浮點數集合和雙精度浮點數集合。
所有虛擬機都必須支持單精度浮點數集合和雙精度浮點數集合。
關於浮點數的內容,其實看起來會非常暈。所以我們暫時可以大致看一下就可以,畢竟關於浮點數如何定義又是另一個標准了。
returnAddress 類型
returnAddress 類型會被 Java 虛擬機的 jsr、ret 和 jsr_w 指令所使用。jsr、ret、jsr_w 這幾條指令以前主要被使用來實現 finally 語句塊,后來改為冗余 finally 塊代碼的方式來實
現,甚至到了 JDK7 時,虛擬機已不允許 Class 文件內出現這幾條指令。那相應地, returnAddress 類型就處於名存實亡的狀態。
簡單地說,returnAddress類型現在已經算是被拋棄了,所以我們只需要大致了解一下有這個東西就好了。
boolean類型
雖然 Java 虛擬機定義了 boolean 這種數據類型,但是只對它提供了非常有限的支持。在Java 虛擬機中沒有任何供 boolean 值專用的字節碼指令,在 Java 語言之中涉及到 boolean類型值的運算,在編譯之后都使用 Java 虛擬機中的 int 數據類型來代替。
可以說,我們學會了int類型,就學會了boolean類型。因為boolean類型就是簡化版的int類型。boolean的虛擬機指令集都是使用int類型的指令集。
引用類型與值
Java 虛擬機中有三種引用類型:類類型(Class Types)、數組類型(Array Types)和接口類型(Interface Types)。這些引用類型的值分別由類實例、數組實例和實現了某個接口的類實例或數組實例動態創建。
不僅原始類型不同,JVM的引用類型與原始類型也是不同的。JVM的引用類型有三種:
- 類類型。對應的值是類實例。
- 數組類型。對應的值時數組實例。
- 接口類型。對應的值時實現了某個接口的類實例或數組實例。
嗯,美滋滋啊。又學到新東西嘞。
數組類型還包含一個單一維度(即長度不由其類型決定)的組件類型(Component Type),一個數組的組件類型也可以是數組。但從任意一個數組開始,如果發現其組件類型也是數組類型的話,繼續重復取這個數組的組件類型,這樣操作不斷執行,最終一定可以遇到組件類型不是數組的情況,這時就把這種類型成為數組類型的元素類型(Element Type)。數組的元素類型必須是原始類型、類類型或者接口類型之中的一種。
這段話很繞,其中有幾個關鍵詞:數組類型、組件類型(Component Type)。其實這段話意思應該是說數組可以有多維的意思,而數組里的類型又可以是其他各種類型。大致意思應該是如此,但我也不是100%確定。這里MARK一下。TODO
在引用類型的值中還有一個特殊的值: null,當一個引用不指向任何對象的時候,它的值就用 null 來表示。Java 虛擬機規范並沒有規定 null 在虛擬機實現中應當怎樣編碼表示。
關於null的定義,了解一下。
運行時數據區
終於到運行時數據區了,其實這塊就是我們經常說的JVM內存模型這些東西。但實際上JVM規范中並沒有這個術語。JVM規范只定義了「運行時數據區」這個術語,指的就是JVM運行時其內存的數據區是怎么樣的,應該包含哪些東西。具體怎么實現,你們各個虛擬機自己打算去。
Java 虛擬機定義了若干種程序運行期間會使用到的運行時數據區,其中有一些會隨着虛擬機啟動而創建,隨着虛擬機退出而銷毀。另外一些則是與線程一一對應的,這些與線程對應的數據區域會隨着線程開始和結束而創建和銷毀。
為什么我們經常用JVM內存模型來概括運行時數據區,就是因為運行時數據區說的概念太過於分散,沒有聯系,所以才會有JVM內存模型這個詞,讓我們把這些東西聯系起來,方便記憶。
從上面這段話,我們可以進行一些概括。首先第一句說到:
Java 虛擬機定義了若干種程序運行期間會使用到的運行時數據區,其中有一些會隨着虛擬機啟動而創建,隨着虛擬機退出而銷毀。
意思是說有些東西會隨着虛擬機啟動而一直存在,而隨着虛擬機退出而銷毀。而另外一句:
另外一些則是與線程一一對應的,這些與線程對應的數據區域會隨着線程開始和結束而創建和銷毀。
上面這句意思是說,又有些數據是隨着線程變化的。
其實總結一下就是:JVM運行時數據區有些數據是一直存在的,被所有線程共享的。而有些線程則是線程私有的,隨着線程開始而創建,結束而銷毀。所以,我們可以將運行時數據區的東西簡單分為兩類:一類是公有的,一類是私有的。
通過這樣一歸類,你對於運行時數據區的概念是不是清晰了許多呢!
可能你還沒感覺到,那是因為到這里你還不知道運行時數據區到底有多少個東西。在這里我先列一下。運行時數據區包括下面幾個部分:
- PC寄存器
- Java虛擬機棧
- Java堆
- 方法區
- 運行時常量池
- 本地方法棧
好了。記住這 6 個東西還真是困難。那我們用我們上面說的,加個公有私有的分類試試看。
- 公有部分包括:Java堆、方法區、運行時常量池。
- 私有部分包括:Java虛擬機棧、本地方法棧、PC寄存器。
怎么樣,這樣一歸類,我相信更容易記住了。而且也更符合我們對JVM的理解。JVM的運行時數據區有哪些東西?
首先,有公有和私有兩個部分,公有包括……私有包括……。
大腦天然喜歡結構化的數據,這種方法才是理解運行時數據區的正確姿勢。
說得有點多了,我們接下來繼續讀JVM規范。
PC寄存器
Java 虛擬機可以支持多條線程同時執行(可參考《Java 語言規范》第 17 章),每一條 Java虛擬機線程都有自己的 PC(Program Counter)寄存器。在任意時刻,一條 Java 虛擬機線程只會執行一個方法的代碼,這個正在被線程執行的方法稱為該線程的當前方法。
從這段描述驗證上面我的理解是對的。PC寄存器就是線程私有的,每個線程都有一個PC寄存器。而PC寄存器是用來存儲當前線程所執行方法的地址。
Java虛擬機棧
每一條 Java 虛擬機線程都有自己私有的 Java 虛擬機棧(Java Virtual Machine Stack)①,這個棧與線程同時創建,用於存儲棧幀(Frames, § 2.6)。
從這段描述我們同樣可以看到,Java虛擬機棧同樣也是線程私有的。Java虛擬機棧的作用就是用來存儲「棧幀」。棧幀這個概念也非常重要,其存儲了調用方法時,方法的局部變量等信息。后續會深入學習「棧幀」這個概念,這里不做深入介紹。
如果線程請求分配的棧容量超過 Java 虛擬機棧允許的最大容量時, Java 虛擬機將會拋出一個 StackOverflowError 異常。
傳說中的StackOverflowError就是因為這塊區域出現了問題。
如果 Java 虛擬機棧可以動態擴展,並且擴展的動作已經嘗試過,但是目前無法申請到足夠的內存去完成擴展,或者在建立新的線程時沒有足夠的內存去創建對應的虛擬機棧,那 Java 虛擬機將會拋出一個 OutOfMemoryError 異常。
這里很有意思。意思是說OutOfMemoryError異常,有可能是因為Stack不夠導致的。
我相信許多對於JVM理解不夠深刻的朋友看到這里會很疑惑,因為他們對於JVM內存模型的理解就是:Java堆、Java棧。但實際上並不是這樣的。
你應該這樣理解:首先,你得知道「運行時數據區」,就是JVM運行時的這塊內存。之后,你要知道這塊區域分為兩個部分:公有和私有。而Java虛擬機棧是分配在堆上的(注意,並不是Java堆),但分配出來的這塊內存是線程私有的。
在創建線程是申請創建Java虛擬機棧,可能有兩種情況。第一種,堆上的內存不夠了,那么無法創建Java虛擬機棧。這就是上面說到的這種情況,會發生OutOfMemoryError異常。另一種情況,就是內存申請成功了,但是在線程創建后,調用方法的層次太深了,之前申請的這部分內存不夠用了。這時候會發生StackOverflowError。
說到這兒,你會發現在《JVM規范》第28頁末尾寫了這么一段話:
譯者注:請讀者注意避免混淆 Stack、 Heap 和 Java (VM) Stack、 Java Heap 的概念, Java 虛擬機的實現本身是由其他語言編寫的應用程序,在 Java 語言程序的角度上看分配在 Java Stack 中的數據,而在實現虛擬機的程序角度上看則可以是分配在 Heap 之中。
這段話真是不太好理解,但實際上沒那么難。首先,我們的JVM是使用其他語言寫的,那么這個要運行的時候是不是要像系統申請內存,那么申請的這一大塊內存就是堆內存。
在這么大一塊內存里,我們又分一部分作為Java堆內存、Java虛擬機棧的內存。也即是說,JVM中的堆內存是系統的堆內存中的堆內存。站在實現JVM的程序來說,是分配在堆上的。而站在Java程序的角度上看,是分配在Java堆上的。也就是說可以想象這樣一個圖表:
- 系統內存有堆內存、棧內存之分。
- JVM啟動,向系統申請一塊內存,系統會分配一塊堆內存給它。當其他應用程序,例如微信啟動,系統也會分配一塊堆內存給它。
- JVM獲得了內存,於是加載類、運行程序。JVM會在獲得的內存分出一部分內存作為Java堆,用於分配對象。
- 當創建線程,那么JVM會在系統給它的、還未分配的內存划出一部分創建Java虛擬機棧,從而線程創建成功。
所以如果增加Java堆的大小,那么可以分配給Java虛擬機棧的內存就變少,那么可以創建的線程就減少。那么就更容易導致StackOverflowError。
Java堆
在 Java 虛擬機中,堆(Heap)是可供各條線程共享的運行時內存區域,也是供所有類實例和數組對象分配內存的區域。
Java堆是所有線程共享的,存放類實例和數組對象。
方法區
在 Java 虛擬機中,方法區(Method Area) 是可供各條線程共享的運行時內存區域。方法區與傳統語言中的編譯代碼儲存區(Storage Area Of Compiled Code)或者操作系統進程的正文段(Text Segment)的作用非常類似,它存儲了每一個類的結構信息,例如運行時常量池(Runtime Constant Pool)、字段和方法數據、構造函數和普通方法的字節碼內容、還包括一些在類、實例、接口初始化時用到的特殊方法(§ 2.9)。
可以看到方法區也是共享的,而且存放的是類的信息。在HotSpot虛擬機中,JDK1.7版本稱其為永久代(Permanent Generation),而在JDK1.8則稱之為元空間(Metaspace)。
運行時常量池
每一個運行時常量池都分配在 Java 虛擬機的方法區之中(§ 2.5.4),在類和接口被加載到虛擬機后,對應的運行時常量池就被創建出來。
運行時常量池分配在JVM的方法區之中。
本地方法棧
Java 虛擬機實現可能會使用到傳統的棧(通常稱之為“C Stacks”)來支持 native 方法(指使用 Java 以外的其他語言編寫的方法)的執行,這個棧就是本地方法棧(Native Method Stack)。當 Java 虛擬機使用其他語言(例如 C 語言)來實現指令集解釋器時,也會使用到本地方法棧。
本地方法指的是使用Java以外的其他語言編寫的代碼,因為有些時候Java無法直接操作一些底層資源,只能通過C或匯編操作。因此需要通過本地方法來實現。
而本地方法棧就是設計用來調用這些非Java語言方法的,其作用與Java虛擬機棧類似。會存放對應的局部變量信息、返回結果等。
本地方法棧同樣會發生StackOverFlowError和OutOfMemoryError異常。
棧幀
棧幀(Frame)是用來存儲數據和部分過程結果的數據結構,同時也被用來處理動態鏈接(Dynamic Linking)、方法返回值和異常分派(Dispatch Exception)。
這里重點記住:存儲數據和部分結果,存儲的部分數據就包括了局部變量。
棧幀隨着方法調用而創建,隨着方法結束而銷毀——無論方法是正常完成還是異常完成(拋出了在方法內未被捕獲的異常)都算作方法結束。
棧幀隨着方法調用而創建,是線程私有的。
棧幀的存儲空間分配在 Java 虛擬機棧(§ 2.5.5)之中,每一個棧幀都有自己的局部變量表(Local Variables, § 2.6.1)、操作數棧(Operand Stack, § 2.6.2)和指向當前方法所屬的類的運行時常量池(§ 2.5.5)的引用。
棧幀分配在Java虛擬機棧,而Java虛擬機棧是私有的,所以棧幀肯定也是私有的。可以這么說,棧幀就是Java虛擬機棧里的一個個元素,每次調用一個方法就push一個棧幀,調用完畢則poll一個棧幀。
這里還說到,每一個棧幀都有自己的:
- 局部變量表
- 操作數棧
- 指向當前方法所屬類的運行時常量池引用
在一條線程之中,只有目前正在執行的那個方法的棧幀是活動的。這個棧幀就被稱為是當前棧幀(Current Frame),這個棧幀對應的方法就被稱為是當前方法(Current Method),定義這個方法的類就稱作當前類(Current Class)。
了解下「當前棧幀」、「當前方法」、「當前類」的概念。
請讀者特別注意,棧幀是線程本地私有的數據,不可能在一個棧幀之中引用另外一條線程的棧幀。
這里原文明確指出:棧幀是線程本地私有的數據!
局部變量表
棧幀中局部變量表的長度由編譯期決定,並且存儲於類和接口的二進制表示之中,既通過方法的Code 屬性(§ 4.7.3)保存及提供給棧幀使用。
局部變量表由編譯期決定,並存在方法的Code屬性。
局部變量使用索引來進行定位訪問,第一個局部變量的索引值為零,局部變量的索引值是從零至小於局部變量表最大容量的所有整數。
局部變量表的索引從零開始。
特別地,當一個實例方法被調用的時候,第 0 個局部變量一定是用來存儲被調用的實例方法所在的對象的引用(即 Java 語言中的“this”關鍵字)。
局部變量第一個局部變量,一定是當前對象的引用,即this關鍵字。
操作數棧
每一個棧幀(§ 2.6)內部都包含一個稱為操作數棧(Operand Stack)的后進先出(Last-In-First-Out, LIFO)棧。
這里要注意一下,Java虛擬機棧包含了棧幀,棧幀包含了操作數棧,就像下面這樣的關系:
Java虛擬機棧 -> 棧幀 -> 操作數棧
它們都是棧的數據結構,操作數棧也是如此。
只不過Java虛擬機棧是存儲一個個線程的信息,棧幀存儲的事一個個方法的信息,操作數棧是存儲方法調用中一個個操作數的信息。
每一個操作數棧的成員(Entry) 可以保存一個 Java 虛擬機中定義的任意數據類型的值,包括 long 和 double 類型。
操作數棧的成員可以是任意數據類型。
這章節接下來的關於浮點數、浮點算法部分,太過於復雜,而且實用性太差。這里不深入了解,直接簡單了解就可以了。
在 Java 虛擬機層面上, Java 語言中的構造函數在《Java 語言規范 (第三版)》(下文簡稱JLS3, § 8.8)是以一個名為
的特殊實例初始化方法的形式出現的, 這個方法名稱是由編譯器命名的,因為它並非一個合法的 Java 方法名字,不可能通過程序編碼的方式實現。
<init>
這個特殊的初始化方法,是在虛擬機層面存在的,而不是程序編碼層面的。可以通過虛擬機的invokespecial指令調用。
一個類或者接口最多可以包含不超過一個類或接口的初始化方法,類或者接口就是通過這個方法完成初始化的(§ 5.5)。這個方法是一個不包含參數的靜態方法,名為
①。這個名字也是由編譯器命名的,因為它並非一個合法的 Java 方法名字,不可能通過程序編碼的方式實現。
與init類似clinit也是一個虛擬機層面的方法,不是程序編碼層面的。但類或接口的clinit方法只能由虛擬機自身隱式調用,沒有任何虛擬機字節碼指令可以調用這個方法。
字節碼指令
Java 虛擬機的指令由一個字節長度的、代表着某種特定操作含義的操作(Opcode)以及跟隨其后的零至多個代表此操作所需參數的操作數(Operands)所構成。
字節碼指令組成為:操作碼+操作數。其中操作碼為一個字節長度,操作數情況由操作碼決定。
大部分的指令都沒有支持整數類型 byte、 char 和 short,甚至
沒有任何指令支持 boolean 類型。編譯器會在編譯期或運行期會將 byte 和 short 類型的數據帶符號擴展(Sign-Extend)為相應的 int 類型數據,將 boolean 和 char 類型數據零位擴展(Zero-Extend)為相應的 int 類型數據。與之類似的,在處理 boolean、 byte、 short 和char 類型的數組時,也會轉換為使用對應的 int 類型的字節碼指令來處理。
在虛擬機中,byte/short/boolean/char 都是用 int 類型來存儲的。規范的下一句也直接指明了。
因此,大多數對於boolean、 byte、 short 和 char 類型數據的操作,實際上都是使用相應的對 int 類型作為運算類型(Computational Type)。
虛擬機指令集大致可以分為下面幾類:
- 加載和存儲指令
- 運算指令
- 類型轉換指令
- 對象創建於操作
- 操作數棧管理指令
- 控制轉移指令
- 方法調用和返回指令
- 拋出異常
- 同步
因為虛擬機指令集很枯燥,就像linux命令一樣,所以這里不深入講。有需要的時候再一個個查就可以了。