1. 程式人生 > >深入理解Java虛擬機器(類檔案結構+類載入機制+位元組碼執行引擎)

深入理解Java虛擬機器(類檔案結構+類載入機制+位元組碼執行引擎)

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

1.類檔案結構

1.1 Class類檔案結構

使用subline Test開啟一個class檔案,其十六進位制程式碼為:

  • Class檔案是一組以8位元組為基礎單位的二進位制流,
  • 各個資料專案嚴格按照順序緊湊排列在class檔案中,
  • 中間沒有任何分隔符,這使得class檔案中儲存的內容幾乎是全部程式執行的程式。

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

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等。

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實現,負責載入<JAVA_HOME>\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指令來是實現“動態型別語言”。

相對的,在編譯期就進行型別檢查過程的語言(如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暫存器裡面。

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

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


本文中,我們分析了虛擬機器在執行程式碼時,如何找到正確的方法、如何執行方法內的位元組碼,以及執行程式碼時涉及的記憶體結構。