1. 程式人生 > >深入理解Java虛擬機(類文件結構+類加載機制+字節碼執行引擎)

深入理解Java虛擬機(類文件結構+類加載機制+字節碼執行引擎)

本地變量 ber 關鍵字 作者 看書 講解 個數 寫入 class類

周誌明的《深入理解Java虛擬機》很好很強大,閱讀起來頗有點費勁,尤其是當你跟隨作者的思路一直探究下去,開始會讓你弄不清方向,難免有些你說的啥子的感覺。但知識不得不學,於是天天看,反復看,就慢慢的理解了。我其實不想說這種硬磨的方法有多好,我甚至不推薦,我建議大家閱讀這本書時,由淺入深,有舍有得,先從宏觀去理解去閱讀,再慢慢深入,有條不紊的看下去。具體來說,當你看書的某一部分時,先看這部分的章節名,了解這部分這一章在講什麽,然後再看某一章,我拿“類文件結構”這一章來說,我必須先知道類文件結構都有什麽,然後看到有魔數、Class文件版本、常量池等,然後我再去看魔數是什麽,Class文件版本在哪裏,常量池是什麽?再深入下去,常量池有字面量和符號引用,再深入下去,,可能你看懂後還想深究,可能你到這裏就已經看不下去了。這都沒事,我想說的是,此時看不下去的知識,就不要看了,略過看下一節,我們先把最表面的那一層看完,了解,再去深入到某個點。這本書的知識就像是一個二叉樹,我們先把上面的那層看完再步步去深入到下一層,我覺得這樣閱讀起來比較輕松,不至於讀到難處還要硬讀下去。本文只對比較簡單的部分進行摘取作為筆記,適合初學者,若想深入還需親自去閱讀原著。

1.類文件結構

1.1 Class類文件結構

使用subline Test打開一個class文件,其十六進制代碼為:

技術分享圖片

  • Class文件是一組以8字節為基礎單位的二進制流,
  • 各個數據項目嚴格按照順序緊湊排列在class文件中,
  • 中間沒有任何分隔符,這使得class文件中存儲的內容幾乎是全部程序運行的程序。

這裏需要明白1個字節是2位(16進制),比如上面圖中開頭的“cafa babe”是4個字節,也就是魔數。其它參考下圖:

技術分享圖片

  1. 一個字節 = 8位(8個二進制位) 1Byte = 8bit
  2. 一個十六進制 = 4個二進制位
  3. 一個字節 = 2個十六進制

1.2 魔數與Class文件的版本

  每個Class文件的頭4個字節稱為魔數,它的唯一作用是確定這個文件是否為一個能被虛擬機接受的Class文件。很多文件儲存標準中都使用魔數來進行身份識別,譬如圖片格式,如 gif 或者 jpeg 等在文件頭中都存有魔數。

? Class 文件的魔數值為:0xCAFEBABE(咖啡寶貝)

  緊接著魔數的4個字節儲存的是Class文件的版本號:第5和第6個字節是次版本號,第7和第8個字節是主版本號。Java版本號是從45開始的,JDK1.1之後的每個JDK大版本發布主版本號向上加1(JDK 1.0 ~ 1.1 使用了 45.0 ~ 45.3 的版本號),

? 高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能運行以後版本的 Class 文件,即使文件格式並未發生任何變化,虛擬機也必須拒絕執行超過其版本號的 Class 文件。

1.3 常量池

  緊接著主次版本號之後的是常量池入口,常量池可以理解為Class文件之中的資源倉庫,它是Class文件結構中與其他項目關聯最多的數據類型,也是占用Class文件空間最大的數據項目之一。

常量池主要存放兩大類常量:字面量和符號引用。字面量比較接近Java語言層面的常量概念,如文本字符串,聲明為final的常量值等。而符號引用則屬於編譯原理方面的概念,包括了下面三類常量:

   1.類和接口的全限定名

   2.字段的名稱和描述符

   3.方法的名稱和描述符

Java代碼在進行Javac編譯的時候,並不像C和C++那樣有“連接”這一步驟,而是在虛擬機加載Class文件的時候進行動態連接。也就是說,在Class文件中不會保存各個方法,字段的最終內存布局信息,因此這些字段,方法的符號引用不經過運行期轉換的話無法得到真正的內存入口地址,也就無法直接被虛擬機使用。當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建時或運行時解析,翻譯到具體的內存地址中。

1.4 訪問標誌

在常量池結束之後,緊接著的兩個字節代表訪問標誌(access_flags),這個標誌用於識別一些類或接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否定義為abstrack類型;如果是類的話,是否被聲明為final等。具體的標誌位以及標誌的含義見表 6-7。

技術分享圖片

1.5 類索引、父索引與接口索引集合

   類索引(this_class)和父類索引(super_class)都是一個u2 類型的數據,而接口索引集合(interfaces)是一組 u2 類型的數據的集合,Class文件中由這三項數據來確定這個類的繼承關系。類索引用於確定這個類的全限定名,父類索引用於確定這個類的弗雷的全限定名。由於Java語言不允許多重繼承,所以父類索引只有一個,除了java.lang.Object 外,所有 Java 類的父類索引都不為 0。接口索引集合就用來描述這個類實現了哪些接口,這些被實現的接口就按implements語句(如果這個類本身是一個接口,則應當是 extends 語句)後的接口順序從左到右排列在接口索引集合中。

1.6 字段表集合

字段表(field_info)用於描述接口或者類中聲明的變量。字段(field)包括類級變量以及實例變量,但不包括在方法內部聲明的局部變量。

字段中包括的信息:字段的作用域(public、private、protected修飾符)、是類級變量還是實例級變量(static修飾符)、可變性(final)、並發可見性(volatile修飾符)、可否序列化(reansient修飾符)、字段數據類型(基本類型、對象、數組)、字段名稱等。在這些信息中,各個修飾符都是布爾值,要麽有,要麽沒有。而字段叫什麽名字、字段被定義成什麽數據類型,這些都是無法固定的,只能引用常量池中的常量來描述。

1.7 方法集合

Class文件存儲格式中對方法的描述與對字段的描述幾乎采用了完全一致的方式,方法表的結構如同字段表一樣依次包括了訪問標誌(access_flags)、名稱索引(name_index)、描述符索引(descriptor_index)、屬性表集合(attributes)幾項,方法裏的 Java 代碼,經過編譯器編譯成字節碼指令後,存放在方法屬性表集合中一個名為 “Code” 的屬性裏面。

與字段表集合相對應的,如果父類方法在子類中沒有被重寫,方法集合中就不會出現來自父類的方法信息。但有可能會出現由編譯器自動添加的方法,最典型的便是類構造器"<clinit>"方法和實例構造器“<init>”方法。

1.8 屬性表集

屬性表(attribute_info)在前面的講解之中已經出現過數次,在 Class 文件、字段表、方法表都可以攜帶子機的屬性表集合,以用於描述某些場景專有的信息。

1.8.1 Code屬性

  Java程序方法體中的代碼經過Javac編譯器處理後,最終變為字節碼指令存儲在Code屬性內。Code屬性出現在方法表的屬性集合中,但並非所有的方法都存在這個屬性,譬如接口或者抽象類中的方法就不存在Code屬性。

1.8.2 Exception屬性

Exceptions 屬性的作用是列舉出方法中可能拋出的受檢查異常(Checked Exceptions),也就是方法描述時在 throws 關鍵字後面列舉的異常。

1.8.3 LineNumberTable屬性

  LineNumberTable 屬性用於描述 Java 源碼行號與字節碼行號(字節碼的偏移量)之間的對應關系。它並不是運行時必需的屬性,但默認會生成到 Class 文件之中,可以在 javac 中分別使用 -g:none 或 -g:lines 選項來取消或要求生成這項信息。如果選擇不生成LineNumberTable 屬性,對程序運行產生的最主要的影響就是當拋出異常時,堆棧中將不會顯示出錯的行號,並且在調試程序的時候,也無法按照源碼行來設置斷點。

1.8.4 LocalVariableTable屬性

  LocalVariableTable 屬性用於描述棧幀中局部變量表中的變量與 Java 源碼中定義的變量之間的關系,它也不是運行時必需的屬性,但默認會生成到 Class 文件之中,可以在 Javac 中分別使用 -g:none 或 -g:vars 選項來取消或要求生成這項信息。如果沒有生成這項屬性,最大的影響就是當其他人引用這個方法時,所有的參數名稱都將會丟失,IDE 將會使用諸如 arg0、arg1 之類的占位符代替原有的參數名,這對程序運行沒有影響,但是會對代碼編寫帶來較大不變,而且在調試期間無法根據參數名稱從上下文獲得參數值。

1.8.5 SourceFile屬性

   SourceFile 屬性用於記錄生成這個 Class 文件的源碼文件名稱。這個屬性也是可選的,可以分別使用 javac 的 -g:none 或 -g:source 選項來關閉或要求生成這項信息。在 Java 中,對於大多數的類來說,類名和文件名是一致的,但是又一些特殊情況(如內部類)例外。如果不生成這項屬性,當拋出異常時,堆棧中將不會顯示出錯代碼所屬的文件名。

1.8.6 ConstantValue屬性

  ConstantValue 屬性的作用是通知虛擬機自動為靜態變量賦值。只有被 static 關鍵字修飾的變量(類變量)才可以使用這項屬性。類似 “int x = 123” 和 “static int x = 123” 這樣的變量定義在 Java 程序中是非常常見的事情,但虛擬機對這兩種變量賦值的方式和時刻都有所不同。對於非 static 類型的變量(也就是實例變量)的賦值是在實例構造器 <init> 方法中進行的;而對於類變量,則有兩種方式可以選擇:在類構造器 <clinit> 方法中或者使用 ConstantValue 屬性。目前 Sun Javac 編譯器的選擇是:如果同時使用 final 和 static 來修飾一個變量(按照習慣,這裏稱 “常量” 更貼切),並且這個變量的數據類型是基本類型或者 java.lang.String 的話,就生成 ConstantValue 屬性來進行初始化,如果這個變量沒有被 final 修飾,或者並非基本類型及字符串,則將會選擇在 <clinit> 方法中進行初始化。

1.8.7 InnerClass屬性

  InnerClass 屬性用於記錄內部類與宿主類之間的關聯。如果一個類中定義了內部類,那編譯器將會為它以及它所包含的內部類生成 InnerClass 屬性。

1.8.8 Deprecated及Synthetic屬性

  Deprecated 和 Synthetic 兩個屬性都屬於標誌類型的布爾屬性,只存在有和沒有的區別,沒有屬性值的概念。

? Deprecated 屬性用於表示某個類、字段或者方法,已經被程序作者定為不再推薦使用,它可以通過在代碼中使用 @deprecated 註釋進行設置。

? Synthetic 屬性代表此字段或者方法並不是由 Java 源碼直接產生的,而是由編譯器自行添加的,在 JDK 1.5 之後,標識一個類、字段或者方法是編譯器自動產生的,也可以設置它們訪問標誌中的 ACC_SYNTHETIC 標誌位,其中最典型的例子就是 Bridge Method。所有由非用戶代碼產生的類、方法及字段都應當至少設置 Synthetic 屬性和 ACC_SYNTHETIC 標誌位中的一項,唯一的例外是實例構造器 “<init>” 方法和類構造器 “<clinit>” 方法。

1.8.9 StackMapTable屬性

StackMapTable 屬性在 JDK 1.6 發布後增加到了 Class 文件規範中,它是一個復雜的變長屬性,位於 Code 屬性的屬性表中。這個屬性會在虛擬機類加載的字節碼驗證階段被新類型檢查驗證器(Type Checker)使用,目的在於代替以前比較消耗性能的基於數據流分析的類型推導驗證器。

1.8.10 Signature屬性

   Signature 屬性在 JDK 1.5 發布後增加到了 Class 文件規範之中,它是一個可選的定長屬性,可以出現於類、屬性表和方法表結構的屬性表中。在 JDK 1.5 中大幅增強了 Java 語言的語法,在此之後,任何類、接口、初始化方法或成員的泛型簽名如果包含了類型變量(Type Variables)或參數化類型(Parameterized Types),則 Signature 屬性會為它記錄泛型簽名信息。

之所以要專門使用這樣一個屬性去記錄泛型類型,是因為 Java 語言的泛型采用的是擦除法實現的偽泛型,在字節碼(Code 屬性)中,泛型信息編譯(類型變量、參數化類型)之後都通通被擦除掉。使用擦除法的好處是實現簡單(主要修改 Javac 編譯器,虛擬機內部只做了很少的改動)、非常容易實現 backport,運行期也能夠節省一些類型所占的內存空間。但壞處是運行期就無法像 C# 等有真泛型支持的語言那樣,將泛型類型與用戶定義的普通類型同等對待,例如運行期做反射時無法獲得泛型信息。Signature 屬性就是為了彌補這個缺陷而增設的,現在 Java 的反射 API 能夠獲取泛型類型,最終的數據來源也就是這個屬性。

1.8.11 BootstrapMethods屬性

  BootstrapMethods 屬性在 JDK 1.7 發布後增加到了 Class 文件規範之中,它是一個復雜的變長屬性,位於類文件的屬性表中。這個屬性用於保存 invokedynamic 指令引用的引導方法限定符。

2.虛擬機類加載機制

程序員將源代碼寫入.Java文件中,經過(javac)編譯,生成.class二進制文件。虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。

2.1 類加載的時機

類生命周期包括:加載、驗證、準備、解析、初始化、使用、卸載 7個階段 。

加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以再初始化階段之後再開始,這個是為了支持Java語言運行時綁定(也成為動態綁定或晚期綁定)

虛擬機規範規定有且只有5種情況必須立即對類進行初始化

  1.遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候

  2.使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化

  3.當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化

  4.當虛擬機啟動時候,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類

  5.當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化

被動引用:

  1.通過子類引用父類的靜態字段,不會導致子類初始化

  2.通過數組定義來引用類,不會觸發此類的初始化

  3.常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

接口的初始化:接口在初始化時,並不要求其父接口全部完成類初始化,只有在正整使用到父接口的時候(如引用接口中定義的常量)才會初始化

2.2 類加載的過程

2.2.1 加載

  1)通過一個類的全限定名類獲取定義此類的二進制字節流

  2)將這字節流所代表的靜態存儲結構轉化為方法區運行時數據結構

  3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口

數組類的創建過程遵循以下規則:

  1)如果數組的組件類型(指的是數組去掉一個維度的類型)是引用類型,那就遞歸采用上面的加載過程去加載這個組件類型,數組C將在加載該組件類型的類加載器的類名稱空間上被標識

  2)如果數組的組件類型不是引用類型(列如int[]組數),Java虛擬機將會把數組C標識為與引導類加載器關聯

  3)數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public

2.2.2 驗證

  驗證階段會完成下面4個階段的檢驗動作:文件格式驗證,元數據驗證,字節碼驗證,符號引用驗證。

(1)文件格式驗證

  第一階段要驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理。這一階段可能包括:

  1).是否以魔數oxCAFEBABE開頭

  2).主、次版本號是否在當前虛擬機處理範圍之內

  3.)常量池的常量中是否有不被支持的常量類型(檢查常量tag標誌)

  4.)指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量

  5.)CONSTANT_Itf8_info 型的常量中是否有不符合UTF8編碼的數據

  6.)Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息

  這個階段的驗證時基於二進制字節流進行的,只有通過類這個階段的驗證後,字節流才會進入內存的方法區進行存儲,所以後面的3個驗證階段全部是基於方法區的存儲結構進行的,不會再直接操作字節流

(2)元數據驗證

  1.這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)

  2.這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)

  3.如果這個類不是抽象類,是否實現類其父類或接口之中要求實現的所有方法

  4.類中的字段、方法是否與父類產生矛盾(列如覆蓋類父類的final字段,或者出現不符合規則的方法重載,列如方法參數都一致,但返回值類型卻不同等)

第二階段的主要目的是對類元數據信息進行語義校驗,保證不存在不符合Java語言規範的元數據信息

(3)字節碼驗證

  第三階段是整個驗證過程中最復雜的一個階段,主要目的似乎通過數據流和控制流分析,確定程序語言是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗後,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件。

  1.保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,列如,列如在操作數棧放置類一個int類型的數據,使用時卻按long類型來加載入本地變量表中

  2.保證跳轉指令不會跳轉到方法體以外的字節碼指令上

  3.保證方法體中的類型轉換時有效的,列如可以把一個子類對象賦值給父類數據類型,這個是安全的,但是吧父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相幹的一個數據類型,則是危險和不合法的

(4)符號引用驗證

發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。

  1.符號引用中通過字符串描述的全限定名是否能找到相對應的類

  2.在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段

  3.符號引用中的類、字段、方法的訪問性是否可被當前類訪問

  對於虛擬機的類加載機制來說,驗證階段是非常重要的,但是不一定必要(因為對程序運行期沒有影響)的階段。如果全部代碼都已經被反復使用和驗證過,那麽在實施階段就可以考慮使用Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

2.2.3 準備

  準備階段是正式為類變量分配內存並設置類變量初始值的階段,這些變量都在方法區中進行分配。這個時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。其次,這裏說的初始值通常下是數據類型的零值。

  假設public static int value = 123;那變量value在準備階段過後的初始值為0而不是123,因為這時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行,但是如果使用final修飾,public static final int value=123; 則在這個階段其初始值設置為123。

2.2.4 解析

  解析階段是虛擬機將常量池內符號引用替換為直接引用的過

2.2.5 初始化

  類的初始化階段是類加載過程的最後一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才正真開始執行類中定義的Java程序代碼(或者說是字節碼)。

  • <clinit>,類構造器方法,在jvm第一次加載class文件時調用,因為是類級別的,所以只加載一次,是編譯器自動收集類中所有類變量(static修飾的變量)和靜態語句塊(static{}),中的語句合並產生的,編譯器收集的順序,是由程序員在寫在源文件中的代碼的順序決定的。

  • <init>,實例構造器方法,在實例創建出來的時候調用,包括調用new操作符;調用Class或java.lang.reflect.Constructor對象的newInstance()方法;調用任何現有對象的clone()方法;通過java.io.ObjectInputStream類的getObject()方法反序列化。

靜態方法與構造方法的執行時機

public class ClassTest {
    static int i = 1;

    static {
        System.out.println("...靜態方法" + i);
    }

    ClassTest() {
        System.out.println("...構造方法");
    }

    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                return super.loadClass(name);
            }
        };
        Class<?> aClass = classLoader.loadClass(ClassTest.class.getName());
        System.out.println("---在準備階段(實例化之前)就已經初始化類變量---");
        Object o = aClass.newInstance();
    }
}

運行結果:

...靜態方法1
---在準備階段(實例化之前)就已經初始化類變量---
...構造方法

2.3 類的加載器

2.3.1 雙親委派模型

  只存在兩種不同的類加載器:啟動類加載器(Bootstrap ClassLoader),使用C++實現,是虛擬機自身的一部分。另一種是所有其他的類加載器,使用JAVA實現,獨立於JVM,並且全部繼承自抽象類java.lang.ClassLoader.

  啟動類加載器(Bootstrap ClassLoader),負責將存放在<JAVA+HOME>\lib目錄中的,或者被-Xbootclasspath參數所制定的路徑中的,並且是JVM識別的(僅按照文件名識別,如rt.jar,如果名字不符合,即使放在lib目錄中也不會被加載),加載到虛擬機內存中,啟動類加載器無法被JAVA程序直接引用。

  擴展類加載器,由sun.misc.Launcher$ExtClassLoader實現,負責加載\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。

應用程序類加載器(Application ClassLoader),由sun.misc.Launcher$AppClassLoader來實現。由於這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般稱它為系統類加載器。負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

技術分享圖片

這張圖表示類加載器的雙親委派模型(Parents?Delegation?model).?雙親委派模型要求除了頂層的啟動加載類外,其余的類加載器都應當有自己的父類加載器。,這裏類加載器之間的父子關系一般不會以繼承的關系來實現,而是使用組合關系來復用父類加載器的代碼。

2.3.2 雙親委派模型的工作過程

  如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都是應該傳送到頂層的啟動類加載器中,只有當父類加載器反饋自己無法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。

這樣做的好處就是:Java類隨著它的類加載器一起具備了一種帶有優先級的層次關系。例如類java.lang.Object,它存放在rt.jar中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型最頂端的啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為java.lang.object的類,並放在程序的ClassPath中,那系統中將會出現多個不同的Object類,Java類型體系中最基礎的行為也就無法保證,應用程序也將會變得一片混亂。

3.虛擬機字節碼執行引擎

3.1 概述

執行引擎是java虛擬機最核心的組成部件之一。虛擬機的執行引擎由自己實現,所以可以自行定制指令集與執行引擎的結構體系,並且能夠執行那些不被硬件直接支持的指令集格式。

所有的Java虛擬機的執行引擎都是一致的:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果。

3.2 棧幀

棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧(Virtual Machine Stack)的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用開始至執行完成的 過程,都對應著一個棧幀在虛擬機棧裏面從入棧到出棧的過程。

技術分享圖片

3.2.1 局部變量表

局部變量表是一組變量值存儲空間,用於存放方法參數和方法內定義的局部變量。在Java程序編譯為Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需要分配的局部變量表的最大容量。

局部變量表建立在線程的堆棧上,是線程的私有數據。

在方法執行時,虛擬機是使用局部變量表完成參數值到參數變量列表的傳遞過程的,如果執行的是實例方法(非static的方法),那局部變量表中第0位索引的Slot默認是用於傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字“this”來訪問到這個隱含的參數。

3.2.2 操作數棧

操作數棧(Qperand Stack)也常稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks數據項中。操作數棧的每一個元素可以是任意的Java數據類型,包括long和double。

當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。

Java虛擬機的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是操作數棧。

3.2.3 動態連接

每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接(Dynamic Linking)。我們知道Class
文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次運行期間轉化為直接引用,這部分稱為動態連接。

3.2.3 方法返回地址

當一個方法開始執行後,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者),是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。

另外一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。

無論采用何種退出方式,在方法退出之後,都需要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的PC計數器的值可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會保存這部分信息。

方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等。

3.2.4 附加信息

虛擬機規範允許具體的虛擬機實現增加一些規範裏沒有描述的信息到棧幀之中,例如與調試相關的信息,這部分信息完全取決於具體的虛擬機實現,這裏不再詳述。在實際開發中,一般會把動態連接、方法返回地址與其他附加信息全部歸為一類,稱為棧幀信息。

3.3 方法調用

方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本(即調用哪一個方法),暫時還不涉及方法內部的具體運行過程。

在程序運行時,進行方法調用是最普遍、最頻繁的操作,但前面已經講過,Class文件的編譯過程中不包含傳統編譯中的連接步驟,一切方法調用在Class文件裏面存儲的都只是符號引用,而不是方法在實際運行時內存布局中的入口地址(相當於之前說的直接引用)。

3.3.1 解析

所有方法調用中的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱為解析(Resolution)。

在Java虛擬機裏面提供了5條方法調用字節碼指令,分別如下。

  • invokestatic:調用靜態方法。
  • invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
  • invokevirtual:調用所有的虛方法。
  • invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象。
  • invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的4條調用指令,分派邏輯是固化在Java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。

3.3.2 分派

1 靜態分派

靜態類型在編譯期可知,而實際類型到運行期才確定下來。

//實際類型變化
Human man=new Man();
man=new Woman();
//靜態類型變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

所有依賴靜態類型來定位方法執行版本的分派動作,都稱為靜態分派。靜態分派最典型的應用就是方法重載。

2 動態分派

在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。最典型的應用就是方法重寫。

3 單分派與多分派

方法的接收者、方法的參數都可以稱為方法的宗量。根據分派基於多少種宗量,可以將分派劃分為單分派和多分派。單分派是根據一個宗量對目標方法進行選擇的,多分派是根據多於一個的宗量對目標方法進行選擇的。

Java在進行靜態分派時,選擇目標方法要依據兩點:一是變量的靜態類型是哪個類型,二是方法參數是什麽類型。因為要根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派類型。

運行時階段的動態分派過程,由於編譯器已經確定了目標方法的簽名(包括方法參數),運行時虛擬機只需要確定方法的接收者的實際類型,就可以分派。因為是根據一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派類型。

註:到JDK1.7時,Java語言還是靜態多分派、動態單分派的語言,未來有可能支持動態多分派。

4 虛擬機動態分派的實現

由於動態分派是非常頻繁的動作,而動態分派在方法版本選擇過程中又需要在方法元數據中搜索合適的目標方法,虛擬機實現出於性能的考慮,通常不直接進行如此頻繁的搜索,而是采用優化方法。

其中一種“穩定優化”手段是:在類的方法區中建立一個虛方法表(Virtual Method Table, 也稱vtable, 與此對應,也存在接口方法表——Interface Method Table,也稱itable)。使用虛方法表索引來代替元數據查找以提高性能。其原理與C++的虛函數表類似。

技術分享圖片

虛方法表中存放的是各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類中該方法相同,都指向父類的實現入口。虛方法表一般在類加載的連接階段進行初始化。

3.3.3 動態類型語言的支持

JDK新增加了invokedynamic指令來是實現“動態類型語言”。

動態類型語言的關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期,滿足這個特征的語言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。

相對的,在編譯期就進行類型檢查過程的語言(如C++和Java等)就是最常用的靜態類型語言。

註意:動態類型語言與動態語言、弱類型語言並不是一個概念,需要區別對待。

3.4 基於棧的字節碼解釋執行引擎

虛擬機如何調用方法的內容已經講解完畢,現在我們來探討虛擬機是如何執行方法中的字節碼指令。

3.4.1 解釋執行

Java語言經常被人們定位為 “解釋執行”語言,在Java初生的JDK1.0時代,這種定義還比較準確的,但當主流的虛擬機中都包含了即時編譯後,Class文件中的代碼到底會被解釋執行還是編譯執行,就成了只有虛擬機自己才能準確判斷的事情。再後來,Java也發展出來了直接生成本地代碼的編譯器[如何GCJ(GNU Compiler for the Java)],而C/C++也出現了通過解釋器執行的版本(如CINT),這時候再籠統的說“解釋執行”,對於整個Java語言來說就成了幾乎沒有任何意義的概念,只有確定了談論對象是某種具體的Java實現版本和執行引擎運行模式時,談解釋執行還是編譯執行才會比較確切

技術分享圖片

Java語言中,javac編譯器完成了程序代碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的字節碼指令流的過程,因為這一部分動作是在Java虛擬機之外進行的,而解釋器在虛擬機內部,所以Java程序的編譯就是半獨立實現的,。

3.4.2 基於棧的指令集與基於寄存器的指令集

Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,依賴操作數棧進行工作。與之相對應的另一套常用的指令集架構是基於寄存器的指令集,?依賴寄存器進行工作

那麽,基於棧的指令集和基於寄存器的指令集這兩者有什麽不同呢?

舉個簡單例子,分別使用這兩種指令計算1+1的結果,基於棧的指令集會是這個樣子:
iconst_1

iconst_1

iadd

istore_0

兩條iconst_1指令連續把兩個常量1壓入棧後,iadd指令把棧頂的兩個值出棧、相加,然後將結果放回棧頂,最後istore_0把棧頂的值放到局部變量表中的第0個Slot中。

如果基於寄存器的指令集,那程序可能會是這個樣子:

mov eax, 1

add eax, 1

mov指令把EAX寄存器的值設置為1,然後add指令再把這個值加1,將結果就保存在EAX寄存器裏面。

基於棧的指令集主要的優點就是可移植,寄存器是由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束。

棧架構的指令集還有一些其他的優點,如代碼相對更加緊湊,編譯器實現更加簡單等。棧架構指令集的主要缺點是執行速度相對來說會稍微慢一些。


本文中,我們分析了虛擬機在執行代碼時,如何找到正確的方法、如何執行方法內的字節碼,以及執行代碼時涉及的內存結構。

深入理解Java虛擬機(類文件結構+類加載機制+字節碼執行引擎)