深刻理解Java虛擬機-虛擬機執行子系統

本博客主要參考周志明老師的《深刻理解Java虛擬機》第二版java

讀書是一種跟大神的交流。閱讀《深刻理解Java虛擬機》受益不淺,對Java虛擬機有初步的認識。這裏寫博客主要出於如下三個目的:一方面是記錄,方便往後閱讀;一方面是加深對內容的理解;一方面是分享給你們,但願對你們有幫助。程序員

《深刻理解Java虛擬機》全書總結以下:web

序號 內容 連接地址
1 深刻理解Java虛擬機-走近Java http://www.javashuo.com/article/p-wmquqpab-n.html
2 深刻理解Java虛擬機-Java內存區域與內存溢出異常 http://www.javashuo.com/article/p-qbxkmyli-a.html
3 深刻理解Java虛擬機-垃圾回收器與內存分配策略 http://www.javashuo.com/article/p-oivmnobw-ds.html
4 深刻理解Java虛擬機-虛擬機執行子系統 http://www.javashuo.com/article/p-qaeyzgca-a.html
5 深刻理解Java虛擬機-程序編譯與代碼優化 http://www.javashuo.com/article/p-gmrxlpja-o.html
6 深刻理解Java虛擬機-高效併發 http://www.javashuo.com/article/p-yuiduguo-b.html

代碼編譯的結果從本地機器碼轉變爲字節碼,是存儲格式發展的一小步,倒是編程語言發展的一大步。編程

類文件結構

概述

咱們所編寫的每一行代碼,要在機器上運行,最終都須要編譯成二進制的機器碼 CPU 才能識別。可是因爲虛擬機的存在,屏蔽了操做系統與 CPU 指令集的差別性,相似於 Java 這種創建在虛擬機之上的編程語言一般會編譯成一種中間格式的文件來存儲,好比咱們今天要聊的字節碼(ByteCode)文件。數組

無關性的基石

Java 虛擬機的設計者在設計之初就考慮並實現了其它語言在 Java 虛擬機上運行的可能性。因此並非只有 Java 語言可以跑在 Java 虛擬機上,時至今日諸如 Kotlin、Groovy、Jython、JRuby 等一大批 JVM 語言都可以在 Java 虛擬機上運行。它們和 Java 語言同樣都會被編譯器編譯成字節碼文件,而後由虛擬機來執行。因此說類文件(字節碼文件)具備語言無關性。安全

在這裏插入圖片描述

Class類文件的結構

Class 文件是一組以 8 位字節爲基礎單位的二進制流,各個數據嚴格按照順序緊湊的排列在 Class 文件中,中間無任何分隔符,這使得整個 Class 文件中存儲的內容幾乎所有都是程序運行的必要數據,沒有空隙存在。當遇到須要佔用 8 位字節以上空間的數據項時,會按照高位在前的方式分割成若干個 8 位字節進行存儲。網絡

Java 虛擬機規範規定 Class 文件格式採用一種相似與 C 語言結構體的僞結構體來存儲數據,這種僞結構體中只有兩種數據類型:無符號數和表。數據結構

  • 無符號數屬於基本的數據類型,以 u一、u二、u四、u8來分別表明 1 個字節、2 個字節、4 個字節和 8 個字節的無符號數,無符號數能夠用來描述數字、索引引用、數量值或者按照 UTF-8 編碼結構構成的字符串值。
  • 是由多個無符號數或者其餘表做爲數據項構成的複合數據類型,全部表都習慣性地以「_info」結尾。表用於描述有層次關係的複合結構的數據,整個 Class 文件就是一張表,它由下表中所示的數據項構成。
類型 名稱 數量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

Class 文件中存儲的字節嚴格按照上表中的順序緊湊的排列在一塊兒。哪一個字節表明什麼含義,長度是多少,前後順序如何都是被嚴格限制的,不容許有任何改變。架構

魔數與 Class 文件的版本

每一個 Class 文件的頭 4 個字節稱爲魔數(Magic Number),它的惟一做用是肯定這個文件是否爲一個能被虛擬機接收的 Calss 文件。之因此使用魔數而不是文件後綴名來進行識別主要是基於安全性的考慮,由於文件後綴名是能夠隨意更改的。Class 文件的魔數值爲「0xCAFEBABE」。併發

緊接着魔數的 4 個字節存儲的是 Class 文件的版本號:第 5 和第 6 兩個字節是次版本號(Minor Version),第 7 和第 8 個字節是主版本號(Major Version)。高版本的 JDK 可以向下兼容低版本的 Class 文件,虛擬機會拒絕執行超過其版本號的 Class 文件。

常量池

主版本號以後是常量池入口,常量池能夠理解爲 Class 文件之中的資源倉庫,它是 Class 文件結構中與其餘項目關聯最多的數據類型,也是佔用 Class 文件空間最大的數據項目之一,同是它仍是 Class 文件中第一個出現的表類型數據項目。

由於常量池中常量的數量是不固定的,因此在常量池入口須要放置一個 u2 類型的數據來表示常量池的容量「constant_pool_count」,和計算機科學中計數的方法不同,這個容量是從 1 開始而不是從 0 開始計數。之因此將第 0 項常量空出來是爲了知足後面某些指向常量池的索引值的數據在特定狀況下須要表達「不引用任何一個常量池項目」的含義,這種狀況能夠把索引值置爲 0 來表示。

Class 文件結構中只有常量池的容量計數是從 1 開始的,其它集合類型,包括接口索引集合、字段表集合、方法表集合等容量計數都是從 0 開始。

常量池中主要存放兩大類常量:字面量符號引用

  • 字面量比較接近 Java 語言層面的常量概念,如字符串、聲明爲 final 的常量值等。
  • 符號引用屬於編譯原理方面的概念,包括瞭如下三類常量:
    • 類和接口的全限定名
    • 字段的名稱和描述符
    • 方法的名稱和描述符

訪問標誌

緊接着常量池以後的兩個字節表明訪問標誌(access_flag),這個標誌用於識別一些類或者接口層次的訪問信息,包括這個 Class 是類仍是接口;是否認義爲 public 類型;是否認義爲 abstract 類型;若是是類的話,是否被申明爲 final 等。具體的標誌位以及標誌的含義見下表:

標誌名稱 標誌值 含義
ACC_PUBLIC 0x0001 是否爲 public 類型
ACC_FINAL 0x0010 是否被聲明爲 final,只有類可設置
ACC_SUPER 0x0020 是否容許使用 invokespecial 字節碼指令的新語意,invokespecial 指令的語意在 JKD 1.0.2 中發生過改變,微聊區別這條指令使用哪一種語意,JDK 1.0.2 編譯出來的類的這個標誌都必須爲真
ACC_INTERFACE 0x0200 標識這是一個接口
ACC_ABSTRACT 0x0400 是否爲 abstract 類型,對於接口或者抽象類來講,此標誌值爲真,其它類值爲假
ACC_SYNTHETIC 0x1000 標識這個類並不是由用戶代碼產生
ACC_ANNOTATION 0x2000 標識這是一個註解
ACC_ENUM 0x4000 標識這是一個枚舉

access_flags 中一共有 16 個標誌位可使用,當前只定義了其中的 8 個,沒有使用到的標誌位要求一概爲 0。

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

類索引(this_class)和父類索引(super_class)都是一個 u2 類型的數據,而接口索引集合(interfaces)是一組 u2 類型的數據集合,Class 文件中由這三項數據來肯定這個類的繼承關係。

  • 類索引用於肯定這個類的全限定名
  • 父類索引用於肯定這個類的父類的全限定名
  • 接口索引集合用於描述這個類實現了哪些接口

字段表集合

字段表集合(field_info)用於描述接口或者類中聲明的變量。字段(field)包括類變量和實例變量,但不包括方法內部聲明的局部變量。下面咱們看看字段表的結構:

類型 名稱 數量
u2 access_flag 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count

字段修飾符放在 access_flags 中,它與類中的 access_flag 很是類似,都是一個 u2 的數據類型。

標誌名稱 標誌值 含義
ACC_PUBLIC 0x0001 字段是否爲 public
ACC_PRIVATE 0x0002 字段是否爲 private
ACC_PROTECTED 0x0004 字段是否爲 protected
ACC_STATIC 0x0008 字段是否爲 static
ACC_FINAL 0x0010 字段是否爲 final
ACC_VOLATILE 0x0040 字段是否爲 volatile
ACC_TRANSIENT 0x0080 字段是否爲 transient
ACC_SYNTHETIC 0x1000 字段是否由編譯器自動生成
ACC_ENUM 0x4000 字段是否爲 enum

方法表集合

Class 文件中對方法的描述和對字段的描述是徹底一致的,方法表中的結構和字段表的結構同樣。

由於 volatile 關鍵字和 transient 關鍵字不能修飾方法,因此方法表的訪問標誌中沒有 ACC_VOLATILE 和 ACC_TRANSIENT。與之相對的,synchronizes、native、strictfp 和 abstract 關鍵字能夠修飾方法,因此方法表的訪問標誌中增長了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 標誌。

對於方法裏的代碼,通過編譯器編譯成字節碼指令後,存放在方法屬性表中一個名爲「Code」的屬性裏面。

屬性表集合

在 Class 文件、字段表、方法表中均可以攜帶本身的屬性表(attribute_info)集合,用於描述某些場景專有的信息。

屬性表集合不像 Class 文件中的其它數據項要求這麼嚴格,不強制要求各屬性表的順序,而且只要不與已有屬性名重複,任何人實現的編譯器均可以向屬性表中寫入本身定義的屬性信息,Java 虛擬機在運行時會略掉它不認識的屬性。

字節碼指令簡介

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》

公有設計和私有實現

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》

Class文件結構的發展

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》

虛擬機類加載機制

概述

咱們的源代碼通過編譯器編譯成字節碼以後,最終都須要加載到虛擬機以後才能運行。虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的 Java 類型,這就是虛擬機的類加載機制

與編譯時須要進行鏈接工做的語言不一樣,Java 語言中類的加載、鏈接和初始化都是在程序運行期間完成的,這種策略雖然會讓類加載時增長一些性能開銷,可是會爲 Java 應用程序提供高度的靈活性,Java 裏天生可動態擴展的語言特性就是依賴運行期間動態加載和動態鏈接的特色實現的。

例如,一個面向接口的應用程序,能夠等到運行時再指定實際的實現類;用戶能夠經過 Java 預約義的和自定義的類加載器,讓一個本地的應用程序運行從網絡上或其它地方加載一個二進制流做爲程序代碼的一部分。

類加載時機

類從被虛擬機從加載到卸載,整個生命週期包含:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading)7 個階段。其中驗證、準備、解析 3 個部分統稱爲鏈接(Linking)。這 7 個階段的發生順序以下圖:

在這裏插入圖片描述

上圖中加載、驗證、準備、初始化和卸載 5 個階段的順序是肯定的,類的加載過程必須按照這種順序循序漸進的開始「注意,這裏說的是循序漸進的開始,並不要求前一階段執行完才能進入下一階段」,而解析階段則不必定:它在某些狀況下能夠在初始化階段以後再開始,這是爲了支持 Java 的動態綁定。

虛擬機規範中對於何時開始類加載過程的第一節點「加載」並無強制約束。可是對於「初始化」階段,虛擬機則是嚴格規定了有且只有如下 5 種狀況,若是類沒有進行初始化,則必須當即對類進行「初始化」(加載、驗證、準備天然須要在此以前開始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 這 4 條字節碼指令;
  2. 使用 java.lang.reflect 包的方法對類進行反射調用的時候;
  3. 當初始化一個類的時候,發現其父類尚未進行初始化的時候,須要先觸發其父類的初始化;
  4. 當虛擬機啓動時,用戶須要指定一個要執行的主類,虛擬機會先初始化這個類;
  5. 當使用 JDK 1.7 的動態語言支持時,若是一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,而且這個方法句柄所對應的類沒有初始化。

「有且只有」以上 5 種場景會觸發類的初始化,這 5 種場景中的行爲稱爲對一個類的主動引用。除此以外,全部引用類的方式都不會觸發初始化,稱爲被動引用。好比以下幾種場景就是被動引用:

  1. 經過子類引用父類的靜態字段,不會致使子類的初始化;
  2. 經過數組定義來引用類,不會觸發此類的初始化;
  3. 常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化;

類加載過程

加載

這裏的「加載」是指「類加載」過程的一個階段。在加載階段,虛擬機須要完成如下 3 件事:

  1. 經過一個類的全限定名來獲取定義此類的二進制字節流;
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構;
  3. 在內存中生成一個表明這個類的 java.lang.Class 對象,做爲方法區這個類的各類數據的訪問入口。

驗證

驗證是鏈接階段的第一步,這一階段的目的是爲了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。驗證階段大體上會完成下面 4 個階段的檢驗動做:

  1. 文件格式驗證:第一階段要驗證字節流是否符合 Class 文件格式的規範,而且可以被當前版本的虛擬機處理。驗證點主要包括:是否以魔數 0xCAFEBABE 開頭;主、次版本號是否在當前虛擬機處理範圍以內;常量池的常量中是否有不被支持的常量類型;Class 文件中各個部分及文件自己是否有被刪除的或者附加的其它信息等等。
  2. 元數據驗證:第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合 Java 語言規範的要求,這個階段的驗證點包括:這個類是否有父類;這個類的父類是否繼承了不容許被繼承的類;若是這個類不是抽象類,是否實現了其父類或者接口之中要求實現的全部方法;類中的字段、方法是否與父類產生矛盾等等。
  3. 字節碼驗證:第三階段是整個驗證過程當中最複雜的一個階段,主要目的是經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。
  4. 符號引用驗證:最後一個階段的校驗發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動做將在鏈接的第三階段–解析階段中發生。符號引用驗證能夠看作是對類自身之外(常量池中的各類符號引用)的形象進行匹配性校驗。

準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區進行分配。這個階段中有兩個容易產生混淆的概念須要強調下:

  • 首先,這時候進行內存分配的僅包括類變量(被 static 修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在 Java 堆中;
  • 其次這裏所說的初始值「一般狀況」下是數據類型的零值。假設一個類變量的定義爲public static int value = 123; 那麼變量 value 在準備階段事後的初始值爲 0 而不是 123,由於這個時候還沒有執行任何 Java 方法,而把 value 賦值爲 123 的 putstatic 指令是程序被編譯以後,存放於類構造器 () 方法之中,因此把 value 賦值爲 123 的動做將在初始化階段纔會執行。

這裏提到,在「一般狀況」下初始值是零值,那相對的會有一些「特殊狀況」:若是類字段的字段屬性表中存在 ConstantsValue 屬性,那在準備階段變量 value 就會被初始化爲 ConstantValue 屬性所指的值。假設上面的類變量 value 的定義變爲 public static final int value = 123;,編譯時 JavaC 將會爲 value 生成 ConstantValue 屬性,在準備階段虛擬機就會根據 ConstantValue 的設置將 value 賦值爲 123。

解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。前面提到過不少次符號引用和直接引用,那麼到底什麼是符號引用和直接引用呢?

  • 符號引用(Symbolic Reference):符號引用以一組符號來描述所引用的目標,符號能夠上任何形式的字面量,只要使用時能無歧義地定位到目標便可。
  • 直接引用(Direct Reference):直接引用能夠是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。

初始化

類初始化階段是類加載過程當中的最後一步,前面的類加載過程當中,除了在加載階段用戶應用程序能夠經過自定義類加載器參與以外,其他動做徹底是由虛擬機主導和控制的。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼。初始階段是執行類構造器 () 方法的過程。

類加載器

虛擬機設計團隊把類加載階段中的「經過一個類的全限定名來獲取描述此類的二進制字節流」這個動做放到 Java 虛擬機外部去實現,以便讓應用程序本身決定如何去獲取所須要的類。實現這個動做的代碼模塊稱爲「類加載器」。

類加載器:類加載器負責加載程序中的類型(類和接口),並賦予惟一的名字予以標識。

類與類加載器

對於任意一個類,都須要由加載它的類加載器和這個類自己一同確立其在 Java 虛擬機的惟一性,每一個類加載器都擁有一個獨立的類名稱空間。也就是說:比較兩個類是否「相等」,只要在這兩個類是由同一個類加載器加載的前提下才有意義,不然,即便這兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不一樣,那這兩個類就一定不相等。

雙親委派模型

從 Java 虛擬機的角度來說,只存在兩種不一樣的類加載器:一種是啓動類加載器(Bootstrap ClassLoader),這個類加載器使用 C++ 來實現,是虛擬機自身的一部分;另外一種就是全部其餘的類加載器,這些類加載器都由 Java 來實現,獨立於虛擬機外部,而且全都繼承自抽象類 java.lang.ClassLoader

從 Java 開發者的角度來看,類加載器能夠劃分爲:

  • 啓動類加載器(Bootstrap ClassLoader):這個類加載器負責將存放在 <JAVA_HOME>\lib 目錄中的類庫加載到虛擬機內存中。啓動類加載器沒法被 Java 程序直接引用,用戶在編寫自定義類加載器時,若是須要把加載請求委派給啓動類加載器,納智捷使用 null 代替便可;
  • 擴展類加載器(Extension ClassLoader):這個類加載器由 sun.misc.Launcher$ExtClassLoader 實現,它負責加載 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變量所指定的路徑中的全部類庫,開發者能夠直接使用擴展類加載器;
  • 應用程序類加載器(Application ClassLoader):這個類加載器由 sun.misc.Launcher$App-ClassLoader 實現。getSystemClassLoader() 方法返回的就是這個類加載器,所以也被稱爲系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫。開發者能夠直接使用這個類加載器,若是應用程序中沒有自定義過本身的類加載器,通常狀況下這個就是程序中默認的類加載器。

咱們的應用程序都是由這 3 種類加載器互相配合進行加載的,在必要時還能夠本身定義類加載器。它們的關係以下圖所示:

在這裏插入圖片描述

上圖中所呈現出的這種層次關係,稱爲類加載器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啓動類加載器之外,其他的類加載器都應當有本身的父類加載器。

類加載器的關係
  1. Bootstrap Classloader 是在Java虛擬機啓動後初始化的。
  2. Bootstrap Classloader 負責加載 ExtClassLoader,而且將 ExtClassLoader的父加載器設置爲 Bootstrap Classloader
  3. Bootstrap Classloader 加載完 ExtClassLoader 後,就會加載 AppClassLoader,而且將 AppClassLoader 的父加載器指定爲 ExtClassLoader
類加載器的做用
Class Loader 實現方式 具體實現類 負責加載的目標
Bootstrap Loader C++ 由C++實現 %JAVA_HOME%/jre/lib/rt.jar以及-Xbootclasspath參數指定的路徑以及中的類庫
Extension ClassLoader Java sun.misc.Launcher$ExtClassLoader %JAVA_HOME%/jre/lib/ext路徑下以及java.ext.dirs系統變量指定的路徑中類庫
Application ClassLoader Java sun.misc.Launcher$AppClassLoader Classpath以及-classpath-cp指定目錄所指定的位置的類或者是jar文檔,它也是Java程序默認的類加載器
類加載器的特色
  • 層級結構:Java裏的類裝載器被組織成了有父子關係的層級結構。Bootstrap類裝載器是全部裝載器的父親。
  • 代理模式: 基於層級結構,類的代理能夠在裝載器之間進行代理。當裝載器裝載一個類時,首先會檢查它在父裝載器中是否進行了裝載。若是上層裝載器已經裝載了這個類,這個類會被直接使用。反之,類裝載器會請求裝載這個類
  • 可見性限制:一個子裝載器能夠查找父裝載器中的類,可是一個父裝載器不能查找子裝載器裏的類。
  • 不容許卸載:類裝載器能夠裝載一個類可是不能夠卸載它,不過能夠刪除當前的類裝載器,而後建立一個新的類裝載器裝載。
類加載器的隔離問題

每一個類裝載器都有一個本身的命名空間用來保存已裝載的類。當一個類裝載器裝載一個類時,它會經過保存在命名空間裏的類全侷限定名(Fully Qualified Class Name) 進行搜索來檢測這個類是否已經被加載了。

JVMDalvik 對類惟一的識別是 ClassLoader id + PackageName + ClassName,因此一個運行程序中是有可能存在兩個包名類名徹底一致的類的。而且若是這兩個不是由一個 ClassLoader 加載,是沒法將一個類的實例強轉爲另一個類的,這就是 ClassLoader 隔離性。

爲了解決類加載器的隔離問題JVM引入了雙親委託機制

雙親委派模型的工做過程是這樣的:若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父類加載器反饋本身沒法完成這個類加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試本身去加載。

這樣作的好處就是 Java 類隨着它的類加載器一塊兒具有了一種帶有優先級的層次關係。例如 java.lang.Object,它放在 rt.jar 中,不管哪個類加載器要加載這個類,最終都是委派給處於模型頂端的啓動類加載器來加載,所以 Object 類在程序的各類類加載器環境中都是同一個類。相反,若是沒有使用雙親委派模型,由各個類加載器自行去加載的話,若是用戶本身編寫了一個稱爲 java.lang.Object 的類,並放在程序的 ClassPath 中,那系統中將會出現多個不一樣的 Object 類,Java 類型體系中最基本的行爲也就沒法保證了。

雙親委派模型對於保證 Java 程序運行的穩定性很重要,但它的實現很簡單,實現雙親委派模型的代碼都集中在 java.lang.ClassLoader 的 loadClass() 方法中,邏輯很清晰:先檢查是否已經被加載過,若沒有則調用父類加載器的 loadClass() 方法,若父加載器爲空則默認使用啓動類加載器做爲父加載器。若是父類加載失敗,拋出 ClassNotFoundException 異常後,再調用本身的 findClass() 方法進行加載。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {
    // 首先,檢查請求的類是否是已經被加載過
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 若是父類拋出 ClassNotFoundException 說明父類加載器沒法完成加載
        }

        if (c == null) {
            // 若是父類加載器沒法加載,則調用本身的 findClass 方法來進行類加載
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}

關於類文件結構和類加載就經過連續的兩篇文章介紹到這裏了,下一篇咱們來聊聊「虛擬機的字節碼執行引擎」。

破壞雙親委派模型

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》

字節碼執行引擎

概述

執行引擎是 Java 虛擬機最核心的組成部分之一。「虛擬機」是相對於「物理機」的概念,這兩種機器都有代碼執行的能力,區別是物理機的執行引擎是直接創建在處理器、硬件、指令集和操做系統層面上的,而虛擬機執行引擎是由本身實現的,所以能夠自行制定指令集與執行引擎的結構體系,而且可以執行那些不被硬件直接支持的指令集格式。

在 Java 虛擬機規範中制定了虛擬機字節碼執行引擎的概念模型,這個概念模型成爲各類虛擬機執行引擎的統一外觀(Facade)。在不一樣的虛擬機實現裏,執行引擎在執行 Java 代碼的時候可能會有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種方式,也可能二者都有,甚至還可能會包含幾個不一樣級別的編譯器執行引擎。但從外觀上來看,全部 Java 虛擬機的執行引擎是一致的:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果。

運行時棧幀結構

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

每個棧幀都包括了局部變量表、操做數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯程序代碼時,棧幀中須要多大的局部變量表,多深的操做數棧都已經徹底肯定了,而且寫入到方法表的 Code 屬性之中,所以一個棧幀須要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決於具體的虛擬機實現。

一個線程中的方法調用鏈可能會很長,不少方法都處於執行狀態。對於執行引擎來講,在活動線程中,只有位於棧頂的棧幀纔是有效的,稱爲當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法成爲當前方法。執行引擎運行的全部字節碼指令對當前棧幀進行操做,在概念模型上,典型的棧幀結構以下圖:

在這裏插入圖片描述

局部變量表

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

操做數棧

操做數棧(Operand Stack)是一個後進先出棧。同局部變量表同樣,操做數棧的最大深度也在編譯階段寫入到 Code 屬性的 max_stacks 數據項中。操做數棧的每個元素能夠是任意的 Java 數據類型,包括 long 和 double。32 位數據類型所佔的棧容量爲 1,64 位數據類型所佔的棧容量爲 2。在方法執行的任什麼時候候,操做數棧的深度都不會超過 max_stacks 數據項中設定的最大值。

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

動態連接

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

方法返回地址

當一個方法開始執行後,只有兩種方式能夠退出這個方法。

一種是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層方法的調用者,是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱爲正常完成出口

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

不管採用何種退出方式,在方法退出後都須要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能須要在棧幀中保存一些信息,用來恢復它的上層方法的執行狀態。通常來講,方法正常退出時,調用者的 PC 計數器的值能夠做爲返回地址,棧幀中極可能會保存這個計數器值。而方法異常退出時,返回地址是要經過異常處理器表來肯定的,棧幀中通常不會保存這部分信息。

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

附加信息

虛擬機規範容許具體的虛擬機實現增長一些規範裏沒有描述的信息到棧幀中,例如與調試相關的信息,這部分信息徹底取決於具體的虛擬機實現。實際開發中,通常會把動態鏈接、方法返回地址與其餘附加信息所有歸爲一類,成爲棧幀信息。

方法調用

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

在程序運行時,進行方法調用是最爲廣泛、頻繁的操做。前面說過 Class 文件的編譯過程是不包含傳統編譯中的鏈接步驟的,一切方法調用在 Class 文件裏面存儲的都只是符號引用,而不是方法在運行時內存佈局中的入口地址(至關於以前說的直接引用)。這個特性給 Java 帶來了更強大的動態擴展能力,但也使得 Java 方法調用過程變得相對複雜起來,須要在類加載期間,甚至到運行期間才能肯定目標方法的直接引用。

解析

全部方法調用中的目標方法在 Class 文件裏都是一個常量池中的符號引用,在類加載的解析階段,會將其中一部分符號引用轉化爲直接引用,這種解析能成立的前提是方法在程序真正運行以前就有一個可肯定的調用版本,而且這個方法的調用版本在運行期是不可改變的。話句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須肯定下來。這類方法的調用稱爲解析(Resolution)。

Java 語言中符合「編譯器可知,運行期不可變」這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特色決定了它們都不可能經過繼承或者別的方式重寫其它版本,所以它們都適合在類加載階段解析。

與之相應的是,在 Java 虛擬機裏提供了 5 條方法調用字節碼指令,分別是:

  • invokestatic:調用靜態方法;
  • invokespecial:調用實例構造器 方法、私有方法和父類方法;
  • invokevirtual:調用全部虛方法;
  • invokeinterface:調用接口方法,會在運行時再肯定一個實現此接口的對象;
  • invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,而後再執行該方法。

只要能被 invokestatic 和 invokespecial 指令調用的方法,均可以在解析階段中肯定惟一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法 4 類,它們在加載的時候就會把符號引用解析爲直接引用。這些方法能夠稱爲非虛方法,與之相反,其它方法稱爲虛方法(final 方法除外)。

Java 中的非虛方法除了使用 invokestatic、invokespecial 調用的方法以外還有一種,就是被 final 修飾的方法。雖然 final 方法是使用 invokevirtual 指令來調用的,可是因爲它沒法被覆蓋,沒有其它版本,因此也無需對方法接受者進行多態選擇,又或者說多態選擇的結果確定是惟一的。在 Java 語言規範中明確說明了 final 方法是一種非虛方法。

解析調用必定是個靜態過程,在編譯期間就能徹底肯定,在類裝載的解析階段就會把涉及的符號引用所有轉變爲可肯定的直接引用,不會延遲到運行期再去完成。而分派(Dispatch)調用則多是靜態的也多是動態的,根據分派依據的宗量數可分爲單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派 4 種分派組合狀況,下面咱們再看看虛擬機中的方法分派是如何進行的。

分派

面向對象有三個基本特徵,封裝、繼承和多態。這裏要說的分派將會揭示多態特徵的一些最基本的體現,如「重載」和「重寫」在 Java 虛擬機中是如何實現的?虛擬機是如何肯定正確目標方法的?

靜態分派

在開始介紹靜態分派前咱們先看一段代碼。

/** * 方法靜態分派演示 */
public class StaticDispatch {

    private static abstract class Human { }

    private static class Man extends Human { }

    private static class Woman extends Human { }

    private void sayHello(Human guy) {
        System.out.println("Hello, guy!");
    }

    private void sayHello(Man man) {
        System.out.println("Hello, man!");
    }

    private void sayHello(Woman woman) {
        System.out.println("Hello, woman!");
    }

    public static void main(String[] args) {

        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
    }
}

運行後這段程序的輸出結果以下:

Hello, guy!
Hello, guy!

稍有經驗的 Java 程序員都能得出上述結論,但爲何咱們傳遞給 sayHello() 方法的實際參數類型是 Man 和 Woman,虛擬機在執行程序時選擇的倒是 Human 的重載呢?要理解這個問題,咱們先弄清兩個概念。

Human man = new Man();

上面這段代碼中的「Human」稱爲變量的靜態類型(Static Type),或者叫作外觀類型(Apparent Type),後面的「Man」稱爲變量爲實際類型(Actual Type),靜態類型和實際類型在程序中均可以發生一些變化,區別是靜態類型的變化僅發生在使用時,變量自己的靜態類型不會被改變,而且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期纔可肯定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什麼。

弄清了這兩個概念,再來看 StaticDispatch 類中 main() 方法裏的兩次 sayHello() 調用,在方法接受者已經肯定是對象「dispatch」的前提下,使用哪一個重載版本,就徹底取決於傳入參數的數量和數據類型。代碼中定義了兩個靜態類型相同可是實際類型不一樣的變量,可是虛擬機(準確的說是編譯器)在重載時是經過參數的靜態類型而不是實際類型做爲斷定依據的。而且靜態類型是編譯期可知的,所以在編譯階段, Javac 編譯器會根據參數的靜態類型決定使用哪一個重載版本,因此選擇了 sayHello(Human) 做爲調用目標,並把這個方法的符號引用寫到 man() 方法裏的兩條 invokevirtual 指令的參數中。

全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,所以肯定靜態分派的動做實際上不是由虛擬機來執行的。

另外,編譯器雖然能肯定方法的重載版本,可是不少狀況下這個重載版本並非「惟一」的,所以每每只能肯定一個「更加合適」的版本。產生這種狀況的主要緣由是字面量不須要定義,因此字面量沒有顯示的靜態類型,它的靜態類型只能經過語言上的規則去理解和推斷。下面的代碼展現了什麼叫「更加合適」的版本。

public class Overlaod {

    static void sayHello(Object arg) {
        System.out.println("Hello, Object!");
    }

    static void sayHello(int arg) {
        System.out.println("Hello, int!");
    }

    static void sayHello(long arg) {
        System.out.println("Hello, long!");
    }

    static void sayHello(Character arg) {
        System.out.println("Hello, Character!");
    }

    static void sayHello(char arg) {
        System.out.println("Hello, char!");
    }

    static void sayHello(char... arg) {
        System.out.println("Hello, char...!");
    }

    static void sayHello(Serializable arg) {
        System.out.println("Hello, Serializable!");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

上面代碼的運行結果爲:

Hello, char!

這很好理解,‘a’ 是一個 char 類型的數據,天然會尋找參數類型爲 char 的重載方法,若是註釋掉 sayHello(chat arg) 方法,那麼輸出結果將會變爲:

Hello, int!

這時發生了一次類型轉換, ‘a’ 除了能夠表明一個字符,還能夠表明數字 97,由於字符 ‘a’ 的 Unicode 數值爲十進制數字 97,所以參數類型爲 int 的重載方法也是合適的。咱們繼續註釋掉 sayHello(int arg) 方法,輸出變爲:

Hello, long!

這時發生了兩次類型轉換,‘a’ 轉型爲整數 97 以後,進一步轉型爲長整型 97L,匹配了參數類型爲 long 的重載方法。咱們繼續註釋掉 sayHello(long arg) 方法,輸出變爲:

Hello, Character!

這時發生了一次自動裝箱, ‘a’ 被包裝爲它的封裝類型 java.lang.Character,因此匹配到了類型爲 Character 的重載方法,繼續註釋掉 sayHello(Character arg) 方法,輸出變爲:

Hello, Serializable!

這裏輸出之因此爲「Hello, Serializable!」,是由於 java.lang.Serializable 是 java.lang.Character 類實現的一個接口,當自動裝箱後發現仍是找不到裝箱類,可是找到了裝箱類實現了的接口類型,因此緊接着又發生了一次自動轉換。char 能夠轉型爲 int,可是 Character 是絕對不會轉型爲 Integer 的,他只能安全的轉型爲它實現的接口或父類。Character 還實現了另一個接口 java.lang.Comparable,若是同時出現兩個參數分別爲 Serializable 和 Comparable 的重載方法,那它們在此時的優先級是同樣的。編譯器沒法肯定要自動轉型爲哪一種類型,會提示類型模糊,拒絕編譯。程序必須在調用時顯示的指定字面量的靜態類型,如:sayHello((Comparable) ‘a’),才能編譯經過。繼續註釋掉 sayHello(Serializable arg) 方法,輸出變爲:

Hello, Object!

這時是 char 裝箱後轉型爲父類了,若是有多個父類,那將在繼承關係中從下往上開始搜索,越接近上層的優先級越低。即便方法調用的入參值爲 null,這個規則依然適用。繼續註釋掉 sayHello(Serializable arg) 方法,輸出變爲:

Hello, char...!

7 個重載方法以及被註釋得只剩一個了,可見變長參數的重載優先級是最低的,這時字符 ‘a’ 被當成了一個數組元素。

前面介紹的這一系列過程演示了編譯期間選擇靜態分派目標的過程,這個過程也是 Java 語言實現方法重載的本質。

動態分派

動態分派和多態性的另外一個重要體現「重寫(Override)」有着密切的關聯,咱們依舊經過代碼來理解什麼是動態分派。

/** * 方法動態分派演示 */
public class DynamicDispatch {

    static abstract class Human {

        abstract void sayHello();
    }

    static class Man extends Human {

        @Override
        void sayHello() {
            System.out.println("Man say hello!");
        }
    }

    static class Woman extends Human {
        @Override
        void sayHello() {
            System.out.println("Woman say hello!");
        }
    }

    public static void main(String[] args){

        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();

        man = new Woman();
        man.sayHello();
    }
}

代碼執行結果:

Man say hello!
Woman say hello!
Woman say hello!

對於上面的代碼,虛擬機是如何肯定要調用哪一個方法的呢?顯然這裏再也不經過靜態類型來決定了,由於靜態類型一樣都是 Human 的兩個變量 man 和 woman 在調用 sayHello() 方法時執行了不一樣的行爲,而且變量 man 在兩次調用中執行了不一樣的方法。致使這個結果的緣由是由於它們的實際類型不一樣。對於虛擬機是如何經過實際類型來分派方法執行版本的,這裏咱們就不作介紹了,有興趣的能夠去看看原著。

咱們把這種在運行期根據實際類型來肯定方法執行版本的分派稱爲動態分派

單分派和多分派

方法的接收者和方法的參數統稱爲方法的宗量,這個定義最先來源於《Java 與模式》一書。根據分派基於多少宗量,可將分派劃分爲單分派多分派

單分派是根據一個宗量來肯定方法的執行版本;多分派則是根據多餘一個宗量來肯定方法的執行版本。

咱們依舊經過代碼來理解(代碼以著名的 3Q 大戰做爲背景):

/** * 單分派、多分派演示 */
public class Dispatch {

    static class QQ { }

    static class QiHu360 { }

    static class Father {

        public void hardChoice(QQ qq) {
            System.out.println("Father choice QQ!");
        }

        public void hardChoice(QiHu360 qiHu360) {
            System.out.println("Father choice 360!");
        }
    }

    static class Son extends Father {

        @Override
        public void hardChoice(QQ qq) {
            System.out.println("Son choice QQ!");
        }

        @Override
        public void hardChoice(QiHu360 qiHu360) {
            System.out.println("Son choice 360!");
        }
    }

    public static void main(String[] args) {

        Father father = new Father();
        Father son = new Son();

        father.hardChoice(new QQ());
        son.hardChoice(new QiHu360());
    }
}

代碼輸出結果:

Father choice QQ!
Son choice 360!

咱們先來看看編譯階段編譯器的選擇過程,也就是靜態分派過程。這個時候選擇目標方法的依據有兩點:一是靜態類型是 Father 仍是 Son;二是方法入參是 QQ 仍是 QiHu360。由於是根據兩個宗量進行選擇的,因此 Java 語言的靜態分派屬於多分派

再看看運行階段虛擬機的選擇過程,也就是動態分派的過程。在執行 son.hardChoice(new QiHu360()) 時,因爲編譯期已經肯定目標方法的簽名必須爲 hardChoice(QiHu360),這時參數的靜態類型、實際類型都不會對方法的選擇形成任何影響,惟一能夠影響虛擬機選擇的因數只有此方法的接收者的實際類型是 Father 仍是 Son。由於只有一個宗量做爲選擇依據,因此 Java 語言的動態分派屬於單分派。

綜上所述,Java 語言是一門靜態多分派、動態單分派的語言。

動態類型語言支持

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》

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

虛擬機如何調用方法已經介紹完了,下面咱們來看看虛擬機是如何執行方法中的字節碼指令的。

解釋執行

Java 語言常被人們定義成「解釋執行」的語言,但隨着 JIT 以及可直接將 Java 代碼編譯成本地代碼的編譯器的出現,這種說法就不對了。只有肯定了談論對象是某種具體的 Java 實現版本和執行引擎運行模式時,談解釋執行仍是編譯執行纔會比較確切。

不管是解釋執行仍是編譯執行,不管是物理機仍是虛擬機,對於應用程序,機器都不可能像人同樣閱讀、理解,而後得到執行能力。大部分的程序代碼到物理機的目標代碼或者虛擬機執行的指令以前,都須要通過下圖中的各個步驟。下圖中最下面的那條分支,就是傳統編譯原理中程序代碼到目標機器代碼的生成過程;中間那條分支,則是解釋執行的過程。

在這裏插入圖片描述

現在,基於物理機、Java 虛擬機或者非 Java 的其它高級語言虛擬機的語言,大多都會遵循這種基於現代編譯原理的思路,在執行前先對程序源代碼進行詞法分析和語法分析處理,把源代碼轉化爲抽象語法樹。對於一門具體語言的實現來講,詞法分析、語法分析以致後面的優化器和目標代碼生成器均可以選擇獨立於執行引擎,造成一個完整意義的編譯器去實現,這類表明是 C/C++。也能夠爲一個半獨立的編譯器,這類表明是 Java。又或者把這些步驟和執行所有封裝在一個封閉的黑匣子中,如大多數的 JavaScript 執行器。

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

許多 Java 虛擬機的執行引擎在執行 Java 代碼的時候都有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種選擇。而對於最新的 Android 版本的執行模式則是 AOT + JIT + 解釋執行,關於這方面咱們後面有機會再聊。

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

Java 編譯器輸出的指令流,基本上是一種基於棧的指令集架構。基於棧的指令集主要的優勢就是可移植,寄存器由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免的要受到硬件約束。棧架構的指令集還有一些其餘優勢,好比相對更加緊湊(字節碼中每一個字節就對應一條指令,而多地址指令集中還須要存放參數)、編譯實現更加簡單(不須要考慮空間分配的問題,全部空間都是在棧上操做)等。

棧架構指令集的主要缺點是執行速度相對來講會稍慢一些。全部主流物理機的指令集都是寄存器架構也從側面印證了這一點。

雖然棧架構指令集的代碼很是緊湊,可是完成相同功能須要的指令集數量通常會比寄存器架構多,由於出棧、入棧操做自己就產生了至關多的指令數量。更重要的是,棧實如今內存中,頻繁的棧訪問也意味着頻繁的內存訪問,相對於處理器來講,內存始終是執行速度的瓶頸。因爲指令數量和內存訪問的緣由,因此致使了棧架構指令集的執行速度會相對較慢。

正是基於上述緣由,Android 虛擬機中採用了基於寄存器的指令集架構。不過有一點不一樣的是,前面說的是物理機上的寄存器,而 Android 上指的是虛擬機上的寄存器。

基於棧的解釋器執行過程

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》

類加載及執行子系統的案例與實戰

感興趣的小夥伴能夠自行閱讀《深刻理解Java虛擬機》