JVM性能優化系列-(3) 虛擬機執行子系統

JVM.jpg

3. 虛擬機執行子系統

3.1 Java跨平臺的基礎

Java剛誕生的宣傳口號:一次編寫,處處運行(Write Once, Run Anywhere),其中字節碼是構成平臺無關的基石,也是語言無關性的基礎。html

Java虛擬機不和包括Java在內的任何語言綁定,它只與Class文件這種特定的二進制文件格式所關聯,這使得任何語言的均可以使用特定的編譯器將其源碼編譯成Class文件,從而在虛擬機上運行。java

Screen Shot 2019-12-22 at 4.41.27 PM.png

3.2 Class類的文件結構

任何一個Class文件都對應着惟一一個類或接口的定義信息,但反過來講,Class文件實際上它並不必定以磁盤文件的形式存在。mysql

Class文件是一組以8位字節爲基礎單位的二進制流。c++

各個數據項目嚴格按照順序緊湊地排列在Class文件之中,中間沒有添加任何分隔符,這使得整個Class文件中存儲的內容幾乎所有是程序運行的必要數據,沒有空隙存在。
Class文件格式採用一種相似於C語言結構體的僞結構來存儲數據,這種僞結構中只有兩種數據類型:無符號數和表git

無符號數屬於基本的數據類型,以u一、u二、u四、u8來分別表明1個字節、2個字節、4個字節和8個字節的無符號數,無符號數能夠用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。github

是由多個無符號數或者其餘表做爲數據項構成的複合數據類型,全部表都習慣性地以「_info」結尾。表用於描述有層次關係的複合結構的數據,整個Class文件本質上就是一張表。web

整個class類的文件結構以下表所示:面試

佔用大小 字段描述 數量
佔用大小 字段描述 數量
u4 magic:魔數,用於標識文件類型,對於java來講是0xCAFEBABE 1
u2 minor_version:次版本號 1
u2 major_version:主版本號 1
u2 constant_pool_count:常量池大小,從1開始而不是0。當這個值爲0時,表示後面沒有常量 1
cp_info constant_pool:#常量池 constant_pool_count-1
u2 access_flags:訪問標誌,標識這個class是類仍是接口、public、abstract、final等 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 attrbutes:#屬性表 attributes_count

可使用javap -verbose輸出class文件的字節碼內容。sql

下面按順序對這些字段進行介紹。數據庫

魔數與Class文件的版本

每一個Class文件的頭4個字節稱爲魔數(Magic Number),它的惟一做用是肯定這個文件是否爲一個能被虛擬機接受的Class文件。使用魔數而不是擴展名來進行識別主要是基於安全方面的考慮,由於文件擴展名能夠隨意地改動。文件格式的制定者能夠自由地選擇魔數值,只要這個魔數值尚未被普遍採用過同時又不會引發混淆便可。

緊接着魔數的4個字節存儲的是Class文件的版本號:第5和第6個字節是次版本號(MinorVersion),第7和第8個字節是主版本號(Major Version)。Java的版本號是從45開始的,JDK 1.1以後的每一個JDK大版本發佈主版本號向上加1高版本的JDK能向下兼容之前版本的Class文件,但不能運行之後版本的Class文件,即便文件格式並未發生任何變化,虛擬機也必須拒絕執行超過其版本號的Class文件。

常量池

常量池中常量的數量是不固定的,因此在常量池的入口須要放置一項u2類型的數據,表明常量池容量計數值(constant_pool_count)。與Java中語言習慣不同的是,這個容量計數是從1而不是0開始的。

常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。

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

訪問標誌

用於識別一些類或者接口層次的訪問信息,包括:

  • 這個Class是類仍是接口;
  • 是否認義爲public類型;
  • 是否認義爲abstract類型;
  • 若是是類的話,是否被聲明爲final等

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

這三項數據來肯定這個類的繼承關係。

  • 類索引用於肯定這個類的全限定名,父類索引用於肯定這個類的父類的全限定名。
  • 因爲Java語言不容許多重繼承,因此父類索引只有一個,除了java.lang.Object以外,全部的Java類都有父類,所以除了java.lang.Object外,全部Java類的父類索引都不爲0。
  • 接口索引集合就用來描述這個類實現了哪些接口,這些被實現的接口將按implements語句(若是這個類自己是一個接口,則應當是extends語句)後的接口順序從左到右排列在接口索引集合中

字段表集合

描述接口或者類中聲明的變量。字段(field)包括類級變量以及實例級變量。
而字段叫什麼名字、字段被定義爲何數據類型,這些都是沒法固定的,只能引用常量池中的常量來描述。

字段表集合中不會列出從超類或者父接口中繼承而來的字段,但有可能列出本來Java代碼之中不存在的字段,譬如在內部類中爲了保持對外部類的訪問性,會自動添加指向外部類實例的字段。

方法表集合

描述了方法的定義,可是方法裏的Java代碼,通過編譯器編譯成字節碼指令後,存放在屬性表集合中的方法屬性表集合中一個名爲「Code」的屬性裏面。

與字段表集合相相似的,若是父類方法在子類中沒有被重寫(Override),方法表集合中就不會出現來自父類的方法信息。但一樣的,有可能會出現由編譯器自動添加的方法,最典型的即是類構造器「<clinit>」方法和實例構造器「<init>」

屬性表集合

存儲Class文件、字段表、方法表都本身的屬性表集合,以用於描述某些場景專有的信息。如方法的代碼就存儲在Code屬性表中。

3.3 字節碼指令

Java虛擬機的指令由一個字節長度的、表明着某種特定操做含義的數字(稱爲操做碼,Opcode)以及跟隨其後的零至多個表明此操做所需參數(稱爲操做數,Operands)而構成。

因爲限制了Java虛擬機操做碼的長度爲一個字節(即0~255),這意味着指令集的操做碼總數不可能超過256條。

大多數的指令都包含了其操做所對應的數據類型信息。例如:
iload指令用於從局部變量表中加載int型的數據到操做數棧中,而fload指令加載的則是float類型的數據。

  • l表明long
  • s表明short
  • b表明byte
  • c表明char
  • f表明float
  • d表明double
  • a表明reference

大部分的指令都沒有支持整數類型byte、char和short,甚至沒有任何指令支持boolean類型。不是每種數據類型和每一種操做都有對應的指令,有一些單獨的指令能夠在必要的時候用在將一些不支持的類型轉換爲可被支持的類型。大多數對於boolean、byte、short和char類型數據的操做,實際上都是使用相應的int類型做爲運算類型。

加載和存儲命令

加載和存儲指令用於將數據在幀棧中的局部變量表和操做數棧之間來回傳遞。

  • 將一個局部變量加載到操做棧:iload、iload_<\n>、lload、lload_<\n>、fload、fload_<\n>、dload、dload_<\n>、aload、aload_<\n>
  • 將一個數值從操做數棧存儲到局部變量表:istore、istore_<\n>、lstore、lstore_<\n>、fstore、fstore_<\n>、dstore、dstore_<\n>、astore、astore_<\n>
  • 將一個參數加載到操做數棧:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m一、iconst_<\i>、lconst_ 、fconst_<\f>、dconst_<\d>
  • 擴充局部變量表的訪問索引的指令:wide

上面帶尖括號的指令其實是表明的一組指令,如iload_0、iload_一、iload_2和iload_3。這些指令把操做數隱含在名稱內,不須要進行取操做數的動做。

運算指令

運算或算術指令用於對兩個操做數棧上的值進行某種特定運算,並把結果從新存入到操做棧頂,可分爲整型數據和浮點型數據指令。byte、short、char和boolean類型的算術指令使用int類型的指令代替。

  • 加法指令:iadd、ladd、fadd、dadd
  • 減法指令:isub、lsub、fsub、dsub
  • 乘法指令:imul、lmul、fmul、dmul
  • 除法指令:idiv、ldiv、fdiv、ddiv
  • 求餘指令:irem、lrem、frem、drem
  • 取反指令:ineg、lneg、fneg、dneg
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
  • 或指令:ior、lor
  • 與指令:iand、land
  • 異或指令:ixor、lxor
  • 局部變量自增指令:iinc
  • 比較指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

類型轉換指令

能夠將兩種不一樣的數值類型進行相互轉換,

  • Java虛擬機直接支持如下數值類型的寬化類型轉換(即小範圍類型向大範圍類型的安全轉換):
  1. int類型到long、float或者double類型。
  2. long類型到float、double類型。
  3. float類型到double類型。
  • 處理窄化類型轉換(Narrowing Numeric Conversions)時,必須顯式地使用轉換指令來完成,這些轉換指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。

對象建立與訪問指令

  • 建立類實例的指令:new
  • 建立數組的指令:newarray、anewarray、multianewarray
  • 訪問類字段和實例字段的實例:getfield、putfield、getstatic、putstatic
  • 把一個數組元素加載到操做數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  • 將一個操做數棧的值存儲到數組元素中的指令:bastore、castore、sastore、iastore、fasotre、dastore、aastore
  • 取數組長度的指令:arraylength
  • 檢查類實例類型的指令:instanceof、checkcast

操做數棧管理指令

  • 將操做數棧的棧頂一個或兩個元素出棧:pop、pop2
  • 複製棧頂一個或兩個數值並將複製值或雙份的複製值從新壓入棧頂:dup、dup二、dup_x一、dup2_x一、dup_x二、dup2_x2
  • 將棧最頂端的兩個數值互換:swap

控制轉移指令

控制轉移指令可讓Java虛擬機有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程序,從概念模型上理解,能夠認爲控制轉移指令就是在有條件或無條件地修改PC寄存器的值。控制轉移指令以下。

  • 條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
  • 複合條件分支:tableswitch、lookupswitch。
  • 無條件分支:goto、goto_w、jsr、jsr_w、ret。

方法調用指令

  • invokevirtual指令用於調用對象的實例方法,根據對象的實際類型進行分派(虛方法分派),這也是Java語言中最多見的方法分派方式。
  • invokeinterface指令用於調用接口方法,它會在運行時搜索一個實現了這個接口方法的對象,找出適合的方法進行調用。
  • invokespecial指令用於調用一些須要特殊處理的實例方法,包括實例初始化方法、私有方法和父類方法。
  • invokestatic指令用於調用類方法(static方法)。
  • invokedynamic指令用於在運行時動態解析出調用點限定符所引用的方法,並執行該方法,前面4條調用指令的分派邏輯都固化在Java虛擬機內部,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
  • 方法調用指令與數據類型無關。

方法返回指令

是根據返回值的類型區分的,包括ireturn(當返回值是boolean、byte、char、short和int類型時使用)lreturnfreturndreturn和areturn,另外還有一條return指令供聲明爲void的方法、實例初始化方法以及類和接口的類初始化方法使用。

異常處理指令

在java程序中,顯式拋出異常的操做都由athrow指令來實現。而在java虛擬機中,處理異常不是由字節碼指令來實現的,而是採用異常表來完成的

同步指令

java虛擬機能夠支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor)來支持的。方法級的同步是隱式的,利用方法表結構中的ACC_SYNCHRONIZED訪問標誌得知。指令序列的同步是由monitorenter和monitorexit兩條指令支持。

3.4 類加載機制

典型面試題:類加載過程?什麼是雙親委派?

這是一個很是典型的面試題,標準回答以下:

通常來講,咱們把 Java 的類加載過程分爲三個主要步驟:加載、連接、初始化。

1. 加載(Loading)

此階段中Java 將字節碼數據從不一樣的數據源讀取到 JVM 中,並映射爲 JVM 承認的數據結構(Class 對象),這裏的數據源多是各類各樣的形態,如 jar 文件、class 文件,甚至是網絡數據源等;若是輸入數據不是 ClassFile 的結構,則會拋出 ClassFormatError。 加載階段是用戶參與的階段,咱們能夠自定義類加載器,去實現本身的類加載過程。

2. 連接(Linking)

這是核心的步驟,簡單說是把原始的類定義信息平滑地轉化入 JVM 運行的過程當中。這裏可進一步細分爲三個步驟:

  • 驗證(Verification),這是虛擬機安全的重要保障,JVM 須要覈驗字節信息是符合 Java 虛擬機規範的,不然就被認爲是 VerifyError,這樣就防止了惡意信息或者不合規的信息危害 JVM 的運行,驗證階段有可能觸發更多 class 的加載。
  • 準備(Preparation),建立類或接口中的靜態變量,並初始化靜態變量的初始值。但這裏的「初始化」和下面的顯式初始化階段是有區別的,側重點在於分配所須要的內存空間,不會去執行更進一步的 JVM 指令。
  • 解析(Resolution),在這一步會將常量池中的符號引用(symbolic reference)替換爲直接引用。在Java 虛擬機規範中,詳細介紹了類、接口、方法和字段等各個方面的解析。

3. 初始化(initialization)

這一步真正去執行類初始化的代碼邏輯,包括靜態字段賦值的動做,以及執行類定義中的靜態初始化塊內的邏輯,編譯器在編譯階段就會把這部分邏輯整理好,父類型的初始化邏輯優先於當前類型的邏輯

雙親委派模型:

簡單說就是當類加載器(Class-Loader)試圖加載某個類型的時候,除非父加載器找不到相應類型,不然儘可能將這個任務代理給當前加載器的父加載器去作。使用委派模型的目的是避免重複加載 Java 類型。

概述

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verificatio)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱爲鏈接(Linking)。

class lifeCycle.png

於初始化階段,虛擬機規範則是嚴格規定了有且只有5種狀況必須當即對類進行「初始化」(而加載、驗證、準備天然須要在此以前開始):

  1. 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,若是類沒有進行過初始化,則須要先觸發其初始化。生成這4條指令的最多見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,若是類沒有進行過初始化,則須要先觸發其初始化。
  3. 當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化。
  4. 當虛擬機啓動時,用戶須要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  5. 當使用JDK 1.7的動態語言支持時,若是一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,而且這個方法句柄所對應的類沒有進行過初始化,則須要先觸發其初始化。

關於靜態變量的初始化,必需要注意如下三種狀況下是不會觸發類的初始化的:

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

下面是測試程序:

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    
    public static int value = 123;
}

public class SubClass extends SuperClass {
    static {
        System.out.println("Subclass init!");
    }
}

public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    
    public static final String HELLOWORLD_STRING = "hello world";
}

如下是對三種狀況的測試程序:

public class NotInitialization {
    public static void main(String[] args) {
        // 1. 只有直接定義這個字段的類纔會被初始化,所以經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。
        // Result: SuperClass init! 123
        System.out.println(SubClass.value);
        
        // 2. 經過數組定義來引用類,不會觸發此類的初始化
        SuperClass[] superClasses = new SubClass[10];
        
        // 3. 常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化
        // Result: hello world
        System.out.println(ConstClass.HELLOWORLD_STRING);
    }
}

加載

在加載階段,虛擬機須要完成下列3件事:

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

驗證

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

  • 文件格式驗證:第一階段要驗證字節流是否符合Class文件格式的規範,而且能被當前版本的虛擬機處理。主要目的是保證輸入的字節流能正確解析並存儲於方法區內,格式上符合描述一個java類型信息的要求。這個階段的驗證是基於二進制字節流進行的,只有經過了這個階段的驗證後,字節流纔會存儲到方法區中,因此後面的3個驗證階段所有是基於方法區的存儲結構進行的,不會再直接操做字節流
  • 元數據驗證:第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合java語言規範的要求。主要目的是對元數據信息進行語義校驗,保證不存在不符合java語言規範的元數據信息
  • 字節碼驗證:第三階段是整個驗證過程當中最複雜的一個階段,主要目的是經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。若是一個類方法體的字節碼沒有經過字節碼驗證,那確定是有問題的;但若是一個方法體經過了字節碼驗證,也不能說明其必定就是安全的
  • 符號引用驗證:最後一個階段的驗證發生在虛擬機符號引用轉化爲直接引用的時候,這個轉化動做將在鏈接的解析階段中發生,能夠看作是對類自身之外的信息進行匹配性校驗。目的是確保解析動做能正常執行

準備階段

是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。

這個階段中有兩個容易產生混淆的概念須要強調一下,首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在Java堆中。

其次,這裏所說的初始值「一般狀況」下是數據類型的零值,假設一個類變量的定義爲:

public static int value=123;

那變量value在準備階段事後的初始值爲0而不是123,由於這時候還沒有開始執行任何Java方法,而把value賦值爲123的putstatic指令是程序被編譯後,存放於類構造器<clinit>()方法之中,因此把value賦值爲123的動做將在初始化階段纔會執行。

表7-1列出了Java中全部基本數據類型的零值:

Java的數據類型.png

假設上面類變量value的定義變爲:public static final int value=123;
編譯時Javac將會爲value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值爲123。

解析階段

是虛擬機將常量池內的符號引用替換爲直接引用的過程。

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用是能無歧義地定位到目標便可。符號引用與虛擬機實現的內存佈局無關,引用的目標不必定已經加載到內存中。各類虛擬機實現的內存佈局能夠各不相同,可是它們能接受地符號引用必須是一致的,由於符號引用地字面量形式明肯定義在java虛擬機規範地Class文件格式中。

  • 直接引用(Direct References):直接引用能夠是直接指向目標的指針、相對偏移量或是一個能直接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不一樣虛擬機實例上翻譯出來的直接引用通常不會相同。若是有了直接引用,那引用的目標一定已經在內存中存在。

初始化

類初始化是類加載過程的最後一步,在這個階段才真正開始執行類中的字節碼。初始化階段是執行類構造器<clinit>()方法的過程。

  • <clinit>()方法與類的構造函數(<init>()方法)不一樣,它不須要顯式調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行以前,父類的<clinit>()方法已經執行完畢。
  • 因爲父類的<clinit>()方法先執行,所以父類中定義的靜態語句塊要先於子類執行。
  • <clinit>()方法對於類或接口來講不是必需的,若是一個類中沒有靜態語句塊,也沒有對變量賦值操做,那麼編譯器能夠不爲這個類生成<clinit>()方法。
  • 接口中不能使用靜態語句塊,但仍然由變量初始化的賦值操做,所以接口與類同樣都會生成<clinit>()方法,但與類不一樣的是,執行接口的<clinit>()方法不須要先執行父接口的<clinit>()方法,只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也同樣不會執行接口的<clinit>()方法。
  • 虛擬機會保證一個類的 ()方法在多線程環境中被正確地加鎖、同步。

3.5 類加載器

類與類加載器

類加載器雖然只用於實現類的加載動做,但在java程序中起到的做用卻遠不止類加載階段。

對於任意一個類,都須要由加載它的類加載器和這個類自己一同確立其在java虛擬機中的惟一性,每一個類加載器,都擁有一個獨立的類命名空間。當一個Class文件被不一樣的類加載器加載時,加載生成的兩個類一定不相等(equals()、isAssignableFrom()、isInstance()、instanceof關鍵字的結果爲false)。

雙親委派機制

從java虛擬機的角度來看,只存在兩種不一樣的類加載器:一種是啓動類加載器(Bootstrap ClassLoader),這個類加載器使用c++實現,是虛擬機的一部分;另外一種是全部其餘的類加載器,這些類加載器都由java實現,獨立於虛擬機外部,而且所有繼承自抽象類java.lang.ClassLoader。java提供的類加載器主要分如下三種:

  • 啓動類加載器(Bootstrap ClassLoader):這個類負責將存放在 \lib目錄中,或者被-Xbootclasspath參數所指定的路徑中的類庫加載到虛擬機內存中。
  • 擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載 \lib\ext目錄中或者被java.ext.dirs系統變量所指定的路徑中的全部類庫,開發者能夠直接使用擴展類加載器。
  • 應用程序類加載器(Application ClassLoader):這個類加載器由sun.misc.Launcher$AppClassLoader實現。因爲這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,因此通常也稱爲系統類加載器,負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者能夠直接使用這個類加載器。

Screen Shot 2019-12-23 at 9.18.17 AM.png

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

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

自定義類加載器

首先看一下實現雙親委派模型的代碼,邏輯就是先檢查類是否已經被加載,若是沒有則調用父加載器的loadClass()方法,若是父加載器爲空則默認使用啓動類加載器做爲父加載器。若是父類加載失敗,拋出ClassNotFoundException異常後,再調用本身的findClass()方法進行加載。

protected Class<?> loadClass(String name, boolean resolve)
      throws ClassNotFoundException
  {
      synchronized (getClassLoadingLock(name)) {
          // 先從緩存查找該class對象,找到就不用從新加載
          Class<?> c = findLoadedClass(name);
          if (c == null) {
              try {
                  if (parent != null) {
                      //若是找不到,則委託給父類加載器去加載
                      c = parent.loadClass(name, false);
                  } else {
                  //若是沒有父類,則委託給啓動加載器去加載
                      c = findBootstrapClassOrNull(name);
                  }
              } catch (ClassNotFoundException e) {
                  // ClassNotFoundException thrown if class not found
                  // from the non-null parent class loader
              }

              if (c == null) {
                  // If still not found, then invoke findClass in order
                  // 若是都沒有找到,則經過自定義實現的findClass去查找並加載
                  c = findClass(name);
              }
          }
          if (resolve) {//是否須要在加載時進行解析
              resolveClass(c);
          }
          return c;
      }
  }

在實現本身的類加載器時,一般有兩種作法,一種是重寫loadClass方法,另外一種是重寫findClass方法。其實這兩種方法本質上差很少,畢竟loadClass也會調用findClass,可是最好不要直接修改loadClass的內部邏輯,以避免破壞雙親委派的邏輯。推薦的作法是隻在findClass裏重寫自定義類的加載方法。

下面例子實現了文件系統類加載器,

public class FileSystemClassLoader extends ClassLoader { 
 
   private String rootDir; 
 
   public FileSystemClassLoader(String rootDir) { 
       this.rootDir = rootDir; 
   } 
 
   protected Class<?> findClass(String name) throws ClassNotFoundException { 
       byte[] classData = getClassData(name); 
       if (classData == null) { 
           throw new ClassNotFoundException(); 
       } 
       else { 
           return defineClass(name, classData, 0, classData.length); 
       } 
   } 
 
   private byte[] getClassData(String className) { 
       String path = classNameToPath(className); 
       try { 
           InputStream ins = new FileInputStream(path); 
           ByteArrayOutputStream baos = new ByteArrayOutputStream(); 
           int bufferSize = 4096; 
           byte[] buffer = new byte[bufferSize]; 
           int bytesNumRead = 0; 
           while ((bytesNumRead = ins.read(buffer)) != -1) { 
               baos.write(buffer, 0, bytesNumRead); 
           } 
           return baos.toByteArray(); 
       } catch (IOException e) { 
           e.printStackTrace(); 
       } 
       return null; 
   } 
 
   private String classNameToPath(String className) { 
       return rootDir + File.separatorChar 
               + className.replace('.', File.separatorChar) + ".class"; 
   } 
}

Class.forName和ClassLoader.loadClass

Class.forName是Class類的方法public static Class<?> forName(String className) throws ClassNotFoundException

ClassLoader.loadClass是ClassLoader類的方法public Class<?> loadClass(String name) throws ClassNotFoundException

Class.forName和ClassLoader.loadClass均可以用來進行類型加載,而在Java進行類型加載的時刻,通常會有多個ClassLoader可使用,並可使用多種方式進行類型加載。

class A {
    public void m() {
        A.class.getClassLoader().loadClass(「B」);
    }
}

A.class.getClassLoader().loadClass(「B」);代碼執行B的加載過程時,通常會有三個概念上的ClassLoader提供使用。

  • CurrentClassLoader,稱之爲當前類加載器,簡稱CCL,在代碼中對應的就是類型A的類加載器。
  • SpecificClassLoader,稱之爲指定類加載器,簡稱SCL,在代碼中對應的是 A.class.getClassLoader(),若是使用任意的ClassLoader進行加載,這個ClassLoader均可以稱之爲SCL。
  • ThreadContextClassLoader,稱之爲線程上下文類加載器,簡稱TCCL,每一個線程都會擁有一個ClassLoader引用,並且能夠經過Thread.currentThread().setContextClassLoader(ClassLoader classLoader)進行切換。

SCL和TCCL能夠理解爲在代碼中使用ClassLoader的引用進行類加載,而CCL卻沒法獲取到其引用,雖然在代碼中CCL == A.class.getClassLoader() == SCL。CCL的加載過程是由JVM運行時來控制的,是沒法經過Java編程來更改的。

雙親委派機制的破壞

爲何須要破壞雙親委派?

由於在某些狀況下父類加載器須要委託子類加載器去加載class文件。受到加載範圍的限制,父類加載器沒法加載到須要的文件,以Driver接口爲例,因爲Driver接口定義在jdk當中的,而其實現由各個數據庫的服務商來提供,好比mysql的就寫了MySQL Connector,那麼問題就來了,DriverManager(也由jdk提供)要加載各個實現了Driver接口的實現類,而後進行管理,可是DriverManager由啓動類加載器加載,只能記載JAVA_HOME的lib下文件,而其實現是由服務商提供的,由系統類加載器加載,這個時候就須要啓動類加載器來委託子類來加載Driver實現,從而破壞了雙親委派,這裏僅僅是舉了破壞雙親委派的其中一個狀況。

Tomcat的類加載機制是違反了雙親委託原則的,對於一些未加載的非基礎類(Object,String等),各個web應用本身的類加載器(WebAppClassLoader)會優先加載,加載不到時再交給commonClassLoader走雙親委託。

如何破壞?

  1. JDK1.2以前,classLoader類中沒有定義findClass,當用戶繼承該類而且修改loadClass的實現時,就可能破壞雙親委派。
  2. 線程上下文類加載器(Thread Context ClassLoader)。這個類加載器能夠經過java.lang.Thread類的setContextClassLoader方法進行設置。若是建立線程時還未設置,它將會從父線程中繼承一個,若是在應用程序的全局範圍內都沒有設置過多的話,那這個類加載器默認即便應用程序類加載器。有了線程上下文加載器,JNDI服務使用這個線程上下文加載器去加載所須要的SPI代碼,也就是父類加載器請求子類加載器去完成類加載的動做,這種行爲實際上就是打通了雙親委派模型的層次結構來逆向使用類加載器,實際上已經違背了雙親委派模型的通常性原則。Java中全部涉及SPI的加載動做基本勝都採用這種方式。例如JNDI,JDBC,JCE,JAXB,JBI等。
  3. 爲了實現熱插拔,熱部署,模塊化,意思是添加一個功能或減去一個功能不用重啓,只須要把這模塊連同類加載器一塊兒換掉就實現了代碼的熱替換。OSGI實現模塊化熱部署的關鍵則是它自定義類加載器機制的實現。

Tomcat類加載器

Tomcat的類加載機制是違反了雙親委託原則的,對於一些未加載的非基礎類(Object,String等),各個web應用本身的類加載器(WebAppClassLoader)會優先加載,加載不到時再交給commonClassLoader走雙親委託

Tomcat是個web容器, 那麼它要解決什麼問題:

  1. 一個web容器可能須要部署兩個應用程序,不一樣的應用程序可能會依賴同一個第三方類庫的不一樣版本,不能要求同一個類庫在同一個服務器只有一份,所以要保證每一個應用程序的類庫都是獨立的,保證相互隔離。
  2. 部署在同一個web容器中相同的類庫相同的版本能夠共享。不然,若是服務器有10個應用程序,那麼要有10份相同的類庫加載進虛擬機,這是扯淡的。
  3. web容器也有本身依賴的類庫,不能於應用程序的類庫混淆。基於安全考慮,應該讓容器的類庫和程序的類庫隔離開來。
  4. web容器要支持jsp的修改,咱們知道,jsp 文件最終也是要編譯成class文件才能在虛擬機中運行,但程序運行後修改jsp已是司空見慣的事情,不然要你何用? 因此,web容器須要支持 jsp 修改後不用重啓。

Tomcat 若是使用默認的類加載機制行不行

答案是不行的。爲何?

第一個問題,若是使用默認的類加載器機制,那麼是沒法加載兩個相同類庫的不一樣版本的,默認的累加器是無論你是什麼版本的,只在意你的全限定類名,而且只有一份。
第二個問題,默認的類加載器是可以實現的,由於他的職責就是保證惟一性。
第三個問題和第一個問題同樣。
第四個問題,咱們要怎麼實現jsp文件的熱修改(樓主起的名字),jsp 文件其實也就是class文件,那麼若是修改了,但類名仍是同樣,類加載器會直接取方法區中已經存在的,修改後的jsp是不會從新加載的。那麼怎麼辦呢?咱們能夠直接卸載掉這jsp文件的類加載器,因此你應該想到了,每一個jsp文件對應一個惟一的類加載器,當一個jsp文件修改了,就直接卸載這個jsp類加載器。從新建立類加載器,從新加載jsp文件。

Tomcat 如何實現本身獨特的類加載機制?

Screen Shot 2019-12-23 at 11.21.18 AM.png

前面3個類加載和默認的一致,CommonClassLoaderCatalinaClassLoaderSharedClassLoaderWebappClassLoader則是Tomcat本身定義的類加載器,它們分別加載/common/*/server/*/shared/*(在tomcat 6以後已經合併到根目錄下的lib目錄下)和/WebApp/WEB-INF/*中的Java類庫。其中WebApp類加載器和Jsp類加載器一般會存在多個實例,每個Web應用程序對應一個WebApp類加載器,每個JSP文件對應一個Jsp類加載器。

  • commonLoader:Tomcat最基本的類加載器,加載路徑中的class能夠被Tomcat容器自己以及各個Webapp訪問;
  • catalinaLoader:Tomcat容器私有的類加載器,加載路徑中的class對於Webapp不可見;
  • sharedLoader:各個Webapp共享的類加載器,加載路徑中的class對於全部Webapp可見,可是對於Tomcat容器不可見;
  • WebappClassLoader:各個Webapp私有的類加載器,加載路徑中的class只對當前Webapp可見;

從圖中的委派關係中能夠看出:

CommonClassLoader能加載的類均可以被Catalina ClassLoader和SharedClassLoader使用,從而實現了公有類庫的共用,而CatalinaClassLoader和Shared ClassLoader本身能加載的類則與對方相互隔離。

WebAppClassLoader可使用SharedClassLoader加載到的類,但各個WebAppClassLoader實例之間相互隔離。

而JasperLoader的加載範圍僅僅是這個JSP文件所編譯出來的那一個.Class文件,它出現的目的就是爲了被丟棄:當Web容器檢測到JSP文件被修改時,會替換掉目前的JasperLoader的實例,並經過再創建一個新的Jsp類加載器來實現JSP文件的HotSwap功能。

下圖展現了Tomcat的類加載流程:

Screen Shot 2019-12-23 at 11.28.25 AM.png

當tomcat啓動時,會建立幾種類加載器:

1. Bootstrap 引導類加載器

加載JVM啓動所需的類,以及標準擴展類(位於jre/lib/ext下)

2. System 系統類加載器

加載tomcat啓動的類,好比bootstrap.jar,一般在catalina.bat或者catalina.sh中指定。位於CATALINA_HOME/bin下。

3. Common 通用類加載器

加載tomcat使用以及應用通用的一些類,位於CATALINA_HOME/lib下,好比servlet-api.jar

4. webapp 應用類加載器

每一個應用在部署後,都會建立一個惟一的類加載器。該類加載器會加載位於 WEB-INF/lib下的jar文件中的class 和 WEB-INF/classes下的class文件。

典型面試題

tomcat 違背了java 推薦的雙親委派模型了嗎?

違背了,雙親委派模型要求除了頂層的啓動類加載器以外,其他的類加載器都應當由本身的父類加載器加載。tomcat 不是這樣實現,tomcat 爲了實現隔離性,沒有遵照這個約定,每一個webappClassLoader加載本身的目錄下的class文件,不會傳遞給父類加載器。

若是tomcat 的 Common ClassLoader 想加載 WebApp ClassLoader 中的類,該怎麼辦?

可使用線程上下文類加載器實現,使用線程上下文加載器,可讓父類加載器請求子類加載器去完成類加載的動做。


參考:

3.6 運行時棧幀結構

棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧的棧元素。典型棧幀結構:

Screen Shot 2019-12-23 at 11.49.57 AM.png

下面對各個部分進行仔細介紹:

局部變量表

局部變量表(Local Variable Table)是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。局部變量表的容量以變量槽(Variable Slot)爲最小單位,虛擬機規範中並無明確指定一個Slot應占用的內存空間大小,只是規定每一個Slot都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的數據,這樣能夠屏蔽32位跟64位虛擬機在內存空間上的差別。

虛擬機經過索引定位的方式使用局部變量表,索引值的範圍從0到最大Slot數量,索引n對應第n個Slot。局部變量表中第0位索引的Slot默認是用於傳遞方法所屬對象實例的引用,即this。

爲了儘量的節省棧幀空間,局部變量表中的Slot是能夠重用的,同時這也影響了垃圾收集行爲。即對已使用完畢的變量,局部變量表仍持有該對象的引用,致使對象沒法被GC回收,佔用大量內存。這也是「不使用的對象應手動賦值爲null」這條推薦編碼規則的緣由。不過從執行角度使用賦null值的操做來優化內存回收是創建在對字節碼執行引擎概念模型的理解之上,代碼在通過編譯器優化後纔是虛擬機真正須要執行的代碼,這時賦null值會被消除掉,所以更優雅的解決辦法是以恰當的變量做用域來控制變量回收時間。

操做數棧

操做數棧(Operand Stack)也常稱操做棧,它是一個後入先出(Last In First Out,LIFO)棧。方法在執行過程當中,經過各類字節碼指令對棧進行操做,出棧/入棧。java虛擬機的解釋執行引擎稱爲「基於棧的執行引擎」,其中所指的「棧」就是操做數棧。

動態鏈接

每一個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用時爲了執行方法調用過程當中的動態鏈接(Dynamic Linking)。

方法返回地址

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

  1. 執行引擎遇到任意一個方法返回的字節碼指令,這個時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱爲調用者),這種退出方式稱爲正常完成出口(Normal Method Invocation Completion)。

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

方法退出的過程實際上就等同於把當前棧幀出棧,所以退出時可能執行的操做有:

  • 恢復上層方法的局部變量表和操做數棧。
  • 把返回值(若是有)壓入調用者棧幀的操做數棧。
  • 調整PC計數器的值以指向方法調用指令後面的一條指定等。

附加信息

虛擬機規範容許具體的虛擬機實現增長一些規範裏沒有描述的信息到棧幀中,稱之爲棧幀信息。

3.7 方法調用

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

解析

解析就是將Class的常量池中的符號引用轉化爲直接引用(內存佈局中的入口地址)。

在java虛擬機中提供了5條方法調用字節碼指令:

  • invokestatic:調用靜態方法
System.exit(1);
==>編譯
iconst_1    ;將1放入棧內
            ;執行System.exit()
invokestatic java/lang/System/exit(I)V
  • invokespecial:調用實例構造器 方法、私有方法和父類方法。
//<init>方法
new StringBuffer()
==>編譯
new java/lang/StringBuffer    ;建立一個StringBuffer對象
dup                           ;將對象彈出棧頂
                              ;執行<init>()來初始化對象
invokespecial java/lang/StringBuffer/<init>()V

//父類方法
super.equals(x);
==>編譯
aload_0   ;將this入棧
aload_1   ;將第一個參數入棧
          ;執行Object的equals()方法
invokespecial java/lang/Object/equals(Ljava/lang/Object;)Z

//私有方法
與父類方法相似
  • invokevirtual:調用全部的虛方法。
X x;
...
x.equals("abc");
==>編譯
aload_1   ;將x入棧
ldc "abc"   ;將「abc」入棧
          ;執行equals()方法
invokevirtual X/equals(Ljava/lang/Object;)Z
  • invokeinterface:調用接口方法,會在運行時再肯定一個實現此接口的對象。
List x;
...
x.toString();
==>編譯
aload_1   ;將x入棧
          ;執行toString()方法
invokeinterface java/util/List/toString()Z
  • invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,而後再執行該方法。

在編譯階段就能夠肯定惟一調用版本的方法有:靜態方法(類名)私有方法實例構造器( 父類方法(super)final方法。其它統稱爲虛方法,在編譯階段沒法肯定調用版本,須要在運行期經過分派將符號引用轉變爲直接引用。

3.8 分派

靜態分派

指在運行時對類內相同名稱的方法根據描述符來肯定執行版本的分派,多見於方法的重載。

下面的例子中,輸出結果均爲hello guy

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

代碼中定義了兩個靜態類型相同但實際類型不一樣的變量,但虛擬機(準確地說是編譯器)在重載時是經過參數的靜態類型而不是實際類型做爲斷定依據的。而且靜態類型是編譯期可知的,所以,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪一個重載版本,因此選擇了sayHello(Human)做爲調用目標。全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,所以肯定靜態分派的動做實際上不是由虛擬機來執行的。

Picture1.png

動態分派

指對於相同方法簽名的方法根據實際執行對象來肯定執行版本的分派。編譯器是根據引用類型來判斷方法是否可執行,真正執行的是實際對象方法。多見於類多態的實現。

動態分配的實現,最經常使用的手段就是爲類在方法區中創建一個虛方法表。虛方法表中存放着各個方法的實際入口地址。若是某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。若是子類中重寫了這個方法,子類方法表中的地址將會替換爲指向子類實現版本的入口地址。PPT圖中,Son重寫了來自Father的所有方法,所以Son的方法表沒有指向Father類型數據的箭頭。可是Son和Father都沒有重寫來自Object的方法,因此它們的方法表中全部從Object繼承來的方法都指向了Object的數據類型。

Screen Shot 2019-12-23 at 12.28.21 PM.png

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

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

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

基於棧的指令集:指令流中的指令大部分都是零地址指令,它們依賴操做數棧進行工做。

基於寄存器的指令集:最典型的就是X86的地址指令集,通俗一點,就是如今咱們主流的PC機中直接支持的指令集架構,這些指令集依賴寄存器工做。

舉個簡單例子,分別使用這兩種指令計算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寄存器裏面。

基於棧的指令集:

優勢:可移植、代碼相對更緊湊、編譯器實現更簡單等
缺點:執行速度慢、完成相同功能的指令數量更多、棧位於內存中

基於寄存器的指令集:

優勢:速度快
缺點:與硬件結合緊密


參考連接:


本文由『後端精進之路』原創,首發於博客 http://teckee.github.io/ , 轉載請註明出處

搜索『後端精進之路』關注公衆號,馬上獲取最新文章和價值2000元的BATJ精品面試課程

後端精進之路.png

相關文章
相關標籤/搜索