天天看點

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

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

目前已經更新完《Java并發程式設計》,《Docker教程》和《JVM性能優化》,歡迎關注【後端精進之路】,輕松閱讀全部文章。

Java并發程式設計:

  • Java并發程式設計系列-(1) 并發程式設計基礎
  • Java并發程式設計系列-(2) 線程的并發工具類
  • Java并發程式設計系列-(3) 原子操作與CAS
  • Java并發程式設計系列-(4) 顯式鎖與AQS
  • Java并發程式設計系列-(5) Java并發容器
  • Java并發程式設計系列-(6) Java線程池
  • Java并發程式設計系列-(7) Java線程安全
  • Java并發程式設計系列-(8) JMM和底層實作原理
  • Java并發程式設計系列-(9) JDK 8/9/10中的并發
Docker教程:
  • Docker系列-(1) 原理與基本操作
  • Docker系列-(2) 鏡像制作與釋出
  • Docker系列-(3) Docker-compose使用與負載均衡
JVM性能優化:
  • JVM性能優化系列-(1) Java記憶體區域
  • JVM性能優化系列-(2) 垃圾收集器與記憶體配置設定政策
  • JVM性能優化系列-(3) 虛拟機執行子系統
  • JVM性能優化系列-(4) 編寫高效Java程式
  • JVM性能優化系列-(5) 早期編譯優化
  • JVM性能優化系列-(6) 晚期編譯優化
  • JVM性能優化系列-(7) 深入了解性能優化

3. 虛拟機執行子系統

3.1 Java跨平台的基礎

Java剛誕生的宣傳口号:一次編寫,到處運作(Write Once, Run Anywhere),其中位元組碼是構成平台無關的基石,也是語言無關性的基礎。

Java虛拟機不和包括Java在内的任何語言綁定,它隻與Class檔案這種特定的二進制檔案格式所關聯,這使得任何語言的都可以使用特定的編譯器将其源碼編譯成Class檔案,進而在虛拟機上運作。

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

3.2 Class類的檔案結構

任何一個Class檔案都對應着唯一一個類或接口的定義資訊,但反過來說,Class檔案實際上它并不一定以磁盤檔案的形式存在。

Class檔案是一組以8位位元組為基礎機關的二進制流。

各個資料項目嚴格按照順序緊湊地排列在Class檔案之中,中間沒有添加任何分隔符,這使得整個Class檔案中存儲的内容幾乎全部是程式運作的必要資料,沒有空隙存在。

Class檔案格式采用一種類似于C語言結構體的僞結構來存儲資料,這種僞結構中隻有兩種資料類型:無符号數和表。

無符号數屬于基本的資料類型,以u1、u2、u4、u8來分别代表1個位元組、2個位元組、4個位元組和8個位元組的無符号數,無符号數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字元串值。

表是由多個無符号數或者其他表作為資料項構成的複合資料類型,所有表都習慣性地以“_info”結尾。表用于描述有層次關系的複合結構的資料,整個Class檔案本質上就是一張表。

整個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檔案的位元組碼内容。

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

魔數與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_m1、iconst_<\i>、lconst_、fconst_<\f>、dconst_<\d>
  • 擴充局部變量表的通路索引的指令:wide

上面帶尖括号的指令實際上是代表的一組指令,如iload_0、iload_1、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、dup2、dup_x1、dup2_x1、dup_x2、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類型時使用)、lreturn、freturn、dreturn和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)。

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

于初始化階段,虛拟機規範則是嚴格規定了有且隻有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中所有基本資料類型的零值:

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

假設上面類變量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):這個類負責将存放在<JAVA_HOME>\lib目錄中,或者被-Xbootclasspath參數所指定的路徑中的類庫加載到虛拟機記憶體中。
  • 擴充類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher$ExtClassLoader實作,它負責加載<JAVA_HOME>\lib\ext目錄中或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴充類加載器。
  • 應用程式類加載器(Application ClassLoader):這個類加載器由sun.misc.Launcher$AppClassLoader實作。由于這個類加載器是ClassLoader中的getSystemClassLoader()方法的傳回值,是以一般也稱為系統類加載器,負責加載使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器。
JVM性能優化系列-(3) 虛拟機執行子系統

雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求(它的搜尋範圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。

使用雙親委派模型來組織類加載器之間的關系,有一個顯而易見的好處就是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 如何實作自己獨特的類加載機制?

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

前面3個類加載和預設的一緻,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是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的類加載流程:

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

當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 中的類,該怎麼辦?

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

參考:

  • https://www.cnblogs.com/aspirant/p/8991830.html
  • https://www.ibm.com/developerworks/cn/java/j-lo-classloader/index.html
  • https://www.jianshu.com/p/f745187e4010
  • https://www.jianshu.com/p/aedee0e14319

3.6 運作時棧幀結構

棧幀(Stack Frame)是用于支援虛拟機進行方法調用和方法執行的資料結構,它是虛拟機運作時資料區中的虛拟機棧的棧元素。典型棧幀結構:

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

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

局部變量表

局部變量表(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)作為調用目标。所有依賴靜态類型來定位方法執行版本的分派動作稱為靜态分派。靜态分派的典型應用是方法重載。靜态分派發生在編譯階段,是以确定靜态分派的動作實際上不是由虛拟機來執行的。

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

動态分派

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

動态配置設定的實作,最常用的手段就是為類在方法區中建立一個虛方法表。虛方法表中存放着各個方法的實際入口位址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的位址入口和父類相同方法的位址入口是一緻的,都指向父類的實作入口。如果子類中重寫了這個方法,子類方法表中的位址将會替換為指向子類實作版本的入口位址。PPT圖中,Son重寫了來自Father的全部方法,是以Son的方法表沒有指向Father類型資料的箭頭。但是Son和Father都沒有重寫來自Object的方法,是以它們的方法表中所有從Object繼承來的方法都指向了Object的資料類型。

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

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寄存器裡面。

基于棧的指令集:

優點:可移植、代碼相對更緊湊、編譯器實作更簡單等

缺點:執行速度慢、完成相同功能的指令數量更多、棧位于記憶體中

基于寄存器的指令集:

優點:速度快

缺點:與硬體結合緊密

參考連結:

  • https://www.jianshu.com/p/62241c6cd5ef

本文由『後端精進之路』原創,首發于部落格 http://teckee.github.io/ , 轉載請注明出處

搜尋『後端精進之路』關注公衆号,立刻擷取最新文章和價值2000元的BATJ精品面試課程。