國內JVM相關書籍NO.1,Java程序員必讀。讀書筆記第五部分對應原書的第七章至第九章,主要介紹虛擬機的類加載機制、字節碼執行引擎,并通過實例和實戰加深對虛擬機執行子系統這一部分的理解。
第七章 虛擬機類加載機制
7.1 概述
- 虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。
- 在Java語言里面,類型的加載、連接和初始化過程都是在程序運行期間完成,這雖然增量一些性能開銷,但是會為Java應用程序提供高度的靈活性。
7.2 類加載的時機
- 類的整個生命周期:加載、驗證、準備、解析、初始化、使用和卸載;其中驗證、準備和解析統稱為連接;
- 虛擬機規范沒有強制約束類加載的時機,但嚴格規定了有且只有5種情況必須立即對類進行初始化:遇到new、getstatic、putstatic和invokestatic指令;對類進行反射調用時如果類沒有進行過初始化;初始化時發現父類還沒有進行初始化;虛擬機啟動指定的主類;動態語言中MethodHandle實例最后解析結果REF_getStatic等的方法句柄對應的類沒有初始化時;
7.3 類加載的過程
7.3.1 加載
- 通過一個類的全限定名來獲取定義此類的二進制字節流;
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
- 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口;
7.3.2 驗證
- 驗證是連接階段的第一步,其目的是確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全;
- 驗證階段是非常重要的,這個階段是否嚴謹決定了Java虛擬機是否能承受惡意代碼的攻擊;
- 校驗動作:文件格式驗證(基于二進制字節流)、元數據驗證(對類的元數據語義分析)、字節碼驗證(對方法體語義分析)、符號引用驗證(對類自身以外的信息進行匹配性校驗);
7.3.3 準備
- 正式為變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在這個方法區中進行分配;
- 需要強調兩點:這時候內存分配的僅包括類變量,而不包括類實例變量;這里所說的初始化通常情況下是數據類型的零值,真正的賦值是在初始化階段,如果是static final的則是直接賦值;
7.3.4 解析
- 解析階段是虛擬機將常量池內的符號引用(如CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等7種)替換為直接引用的過程;
- 符號引用可以是任何形式的字面量,與虛擬機實現的內存布局無關,引用的目標并不一定已經加載到內存中;而直接引用是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄,它和虛擬機實現的內存布局相關,引用的目標必定以及在內存中存在;
- 對同一個符號引用進行多次解析請求是很常見的事情,虛擬機實現可以對第一次解析的結果進行緩存;
7.3.5 初始化
- 是類加載過程的最后一步,真正開始執行類中定義的Java程序代碼(或者說是字節碼);
- 初始化階段是執行類構造器<clinit>方法的過程,該方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合并產生的;
- <clinit>方法與類的構造函數(或者說是實例構造器<init>方法)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>方法執行之前,父類的<clinit>方法已執行完畢;
- 執行接口的<clinit>方法不需要先執行父接口的<clinit>方法,只有當父接口中定義的變量使用時父接口才會初始化,接口的實現類在初始化時也一樣不會執行接口的<clinit>方法;
- <clinit>方法初始化是加鎖阻塞等待的,應當避免在<clinit>方法中有耗時很長的操作;
7.4 類加載器
- 虛擬機設計團隊把類加載階段的“通過一個類的全限定名來獲取描述此類的二進制字節流”這個動作放到虛擬機外部去實現,實現這個動作的代碼模塊稱為類加載器;
- 這時Java語言的一項創新,也是Java語言流行的重要原因,在類層次劃分、OSGI、熱部署、代碼加密等領域大放異彩;
7.4.1 類與類加載器
- 對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機的唯一性,每一個類加載器都擁有一個獨立的類名稱空間;
- 比較兩個類是否相等(如Class對象的equals方法、isAssignableFrom方法、isInstance方法),只有在這兩個類是由同一個類加載器加載的前提下才有意義;
7.4.2 雙親委派模型
雙親委派模型
- 三種系統提供的類加載器:啟動類加載器(Bootstrap ClassLoader)、擴展類加載器(Extension ClassLoader)、應用程序類加載器(Application ClassLoader);
- 雙親委派模型要求除了頂層的啟動類加載器外,其他的類加載器都應當有自己的父類加載器,這里一般不會以繼承的關系來實現,而是使用組合的關系來復用父加載器的代碼;
- 其工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,只有父類加載器反饋自己無法完成這個加載請求時(它的搜索范圍中沒有找到所需的類),子加載器才會嘗試自己去加載;
- 這樣的好處是Java類隨著它的類加載器具備了一種帶有優先級的層次關系,對保證Java程序的穩定運作很重要;
- 實現雙親委派的代碼都集中在java.lang.ClassLoader的loadClass方法中,邏輯清晰易懂;
7.4.3 破壞雙親委派模型
- 上一小節的雙親委派模型是Java設計者推薦給開發者的類加載器實現方法,但不是一個強制性的約束模型;
- 典型的兩種情況:為了解決JNI接口提供者(SPI)引入的線程上下文類加載器;為了程序動態性加強的OSGI的Bundle類加載器;
7.5 本章小結
本章介紹了類加載過程的加載、驗證、準備、解析和初始化五個階段中虛擬機進行了哪些動作,還介紹了類加載器的工作原理及其對虛擬機的意義。下一章將一起看看虛擬機如果執行定義在Class文件里的字節碼。
第八章 虛擬機字節碼執行引擎
8.1 概述
- 執行引擎是Java虛擬機最核心的組成部分之一,區別于物理機的執行引擎是直接建立在處理器、硬件、指令集和操作系統層面上的,虛擬機的執行引擎是自己實現的,可以自行制定指令集與執行引擎的結構體系,并且能夠執行哪些不被硬件直接支持的指令集格式;
- 在虛擬機規范中制定了虛擬機字節碼執行引擎的概念模型,該模型成為各種虛擬機執行引擎的統一外觀;
- 在不同的虛擬機實現里面,執行引擎在執行Java代碼時可能會有解釋執行和編譯執行兩種選擇,也可能兩者兼備,甚至還可能會包含幾個不同級別的編譯器執行引擎,但從外觀來說是一致的:輸入的都是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果。
8.2 運行時棧幀結構
運行時棧幀結構
- 棧幀是用于支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧的棧元素;
- 棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息,每一個方法從調用開始至執行完成的過程,都對應著一個棧幀在虛擬機里面從入棧到出棧的過程;
- 棧幀需要分配多少內存在編譯時就完全確定并寫入到方法表的Code屬性之中了,不會受到程序運行期變量數據的影響;
- 對于執行引擎來說,在活動線程中只有位于棧頂的棧幀才算有效的,稱為當前棧幀,與這個棧幀相關聯的方法稱為當前方法,執行引擎運行的所有字節碼指令都只針對當前棧幀進行操作。
8.2.1 局部變量表
- 是一組變量值存儲空間,用于存放方法參數和方法內部定義的局部變量,Code屬性的max_locals確定了該方法所需要分配的局部變量表的最大容量;
- 其容量以變量槽(Variable Slot)為最小單位,虛擬機規范允許Slot的長度隨處理器、操作系統或虛擬機的不同而發生變化;
- 一個Slot可以存放一個32位以內的數據類型,包括boolean、byte、char。short、int、float、reference和returnAddress這八種類型;對于64位的數據類型(long和double),虛擬機會以高位對齊的方式為其分配兩個連續的Slot空間;
8.2.2 操作數棧
- 也常稱為操作棧,它是一個后入先出棧;Code屬性的max_stacks確定了其最大深度;
- 比如整數加法的字節碼指令iadd在運行的時候操作數棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會將這兩個int值出棧并相加,然后將相加的結果入棧;
- 操作數棧中元素的類型必須與字節碼指令的序列嚴格匹配;
- Java虛擬機的解釋執行引擎稱為“基于棧的執行引擎”,其中所指的棧就是操作數棧;
8.2.3 動態連接
- 每個棧幀都包含一個執行運行時常量池中該棧幀所屬方法引用,持有這個引用是為了支持方法調用過程中的動態連接(Dynamic Linking);
- Class文件的常量池的符號引用,有一部分在類加載階段或者第一次使用時就轉換為直接引用,這種稱為靜態解析,而另外一部分在每一次運行期間轉換為直接引用,這部分稱為動態連接;
8.2.4 方法返回地址
- 退出方法的方式:正常完成出口和異常完成出口;
- 方法退出的過程實際上就等同于把當前棧幀出棧,因此退出時可能只需的操作有:恢復上層方法的局部變量表和操作數棧,把返回值壓入調用者棧幀的操作數中,調整PC計數器的值以只需方法調用指令后面的一套指令等;
8.2.5 附加信息
- 虛擬機規范允許具體的虛擬機實現增加一些規范里沒有描述的信息到棧幀中,例如與調試相關的信息,這部分完成取決于具體的虛擬機實現;
方法調用
- 方法調用并不等同于方法執行,方法調用階段唯一的任務就是確定被調用方法的版本即調用哪一個方法,暫時還不涉及方法內部的具體運行過程;
- Class文件的編譯過程中不報警傳統編譯的連接步驟,一切方法調用在Class文件里面存儲的都只是符號引用,而不是方法在實際運行時內存布局的入口地址。這個特性給Java帶來了更強大的動態擴展能力,但也使得Java方法調用過程變得相對復雜;
8.3.1 解析
- 方法在程序真正運行之前就有一個可確定的調用版本,并且這個方法的調用版本在運行期是不可改變的,這類方法的調用稱為解析;
- 在Java語言中符合編譯器可知、運行期不可變這個要求的方法,主要包括靜態方法和私有方法兩大類;
- 五條方法調用字節碼指令:invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic;
- 解析調用是一個靜態的過程,在編譯期間就完全確定,在類加載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用;而分派調用則可能是靜態的也可能是動態的;
8.3.2 分派
- 靜態分派:“Human man = new Man();”語句中Human稱為變量的靜態類型,后面的Man稱為變量的實際類型;靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,并且最終的靜態類型是在編譯器可知的;而實際類型的變化在運行期才確定,編譯器在編譯程序的時候并不知道一個對象的實際類型是什么;編譯器在重載時是通過參數的靜態類型而不是實際類型作為判定依據的;所有根據靜態類型來定位方法執行版本的分派動作稱為靜態分派,其典型應用是方法重載;
- 動態分派:invokevirtual指令執行的第一步就是在運行期間確定接收者的實際類型,所以兩次調用中invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質;我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派;
- 單分派與多分派:方法的接收者與方法的參數統稱為方法的宗量,根據分派基于多少種宗量,可以將分派分為單分派(根據一個宗量對目標方法進行選擇)與多分派(根據多于一個宗量對目標方法進行選擇)兩種;今天的Java語言是一門靜態多分派、動態單分派的語言;
- 虛擬機動態分派的實現:在方法區中建立一個虛方法表(Virtual Method Table),使用虛方法表索引來代替元數據查找以提高性能;方法表一般在類加載的連接階段進行初始化,準備了類的變量初始化值后,虛擬機會把該類的方法表也初始化完畢;
8.3.3 動態類型語言支持
- JDK 1.7發布增加的invokedynamic指令實現了“動態類型語言”支持,也是為JDK 1.8順利實現Lambda表達式做技術準備;
- 動態類型語言的關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯器,比如JavaScript、Python等;
- Java語言在編譯期間就將方法完整的符號引用生成出來,作為方法調用指令的參數存儲到Class文件中;這個符號引用包含了此方法定義在哪個具體類型之中、方法的名字以及參數順序、參數類型和方法返回值等信息;而在ECMAScript等動態語言中,變量本身是沒有類型的,變量的值才具有類型,編譯時最多只能確定方法名稱、參數、返回值這些信息,而不會去確定方法所在的具體類型;變量無類型而變量值才有類型,這個特點也是動態類型語言的一個重要特征;
- JDK 1.7實現了JSR-292,新加入的java.lang.invoke包的主要目的是在之前單純依靠符號引用來確定調用的目標方法外,提供一種新的動態確定目標方法的機制,稱為MethodHandle;
- 從本質上講,Reflection(反射)和MethodHandle機制都是在模擬方法調用,但Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用,前者是重量級,而后者是輕量級;另外前者只為Java語言服務,后者可服務于所有Java虛擬機之上的語言;
- 每一處含有invokedynamic指令的位置都稱為“動態調用點(Dynamic Call Site)”,這條指令的第一個參數不再是代表符號引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量(可以得到引導方法、方法類型和名稱);
- invokedynamic指令與其他invoke指令的最大差別就是它的分派邏輯不是由虛擬機決定的,而是由程序員決定的;
8.4 基于棧的字節碼解釋執行引擎
上節主要講虛擬機是如何調用方法的,這節探討虛擬機是如何執行方法中的字節碼指令的。
8.4.1 解釋執行
- 只有確定了談論對象是某種具體的Java實現版本和執行引擎運行模式時,談解釋執行還是編譯執行才比較確切;
- Java語言中,javac編譯器完成了程序代碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的字節碼指令流的過程;因為這一部分動作是在Java虛擬機之外進行的,而解釋器在虛擬機的內部,所以Java程序的編譯就是半獨立的實現;
8.4.2 基于棧的指令集與基于寄存器的指令集
- Java編譯器輸出的指令集,基本上是一種基于棧的指令集架構,指令流中的指令大部分是零地址指令,它們依賴操作數棧進行工作;
- 基于棧的指令集主要的優點是可移植性,寄存器由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束;主要缺點是執行速度相對來說會稍慢一點;
8.4.3 基于棧的解釋器執行過程
一段簡單的算法代碼
public int calc(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
上述代碼的字節碼表示
public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0:bipush 100
2:istore_1
3:sipush 200
6:istore_2
7:sipush 300
10:istore_3
11:iload_1
12:iload_2
13:iadd
14:iload_3
15:imul
16:ireturn
javap提示這段代碼需要深度為2的操作數棧和4個Slot的局部變量空間,作者根據這些信息畫了示意圖來說明執行過程中的變化情況:
執行偏移地址為0的指令
執行偏移地址為0的指令
執行偏移地址為2的指令
執行偏移地址為2的指令
執行偏移地址為11的指令
執行偏移地址為11的指令
執行偏移地址為12的指令
執行偏移地址為12的指令
執行偏移地址為13的指令
執行偏移地址為13的指令
執行偏移地址為14的指令
執行偏移地址為14的指令
執行偏移地址為16的指令
執行偏移地址為16的指令
注:上面的執行過程僅僅是一種概念模型,虛擬機中解析器和即時編譯器會對輸入的字節碼進行優化。
8.5 本章小結
本章分析了虛擬機在執行代碼時,如何找到正確的方法、如何執行方法內的字節碼以及執行代碼時涉及的內存結構。這第六、七、八三章中,我們針對Java程序是如何存儲的、如何載入的以及如何執行的問題進行了講解,下一章一起看看這些理論知識在具體開發中的經典應用。
第九章 類加載及執行子系統的案例與實戰
9.1 概述
- 在Class文件格式與執行引擎這部分中,用戶的程序能直接影響的內容并不多;
- 能通過程序進行操作的,主要是字節碼生成與類加載器這兩部分的功能,但僅僅在如何處理這兩點上,就已經出現了許多值得欣賞和借鑒的思路;
9.2 案例分析
9.2.1 Tomcat:正統的類加載器架構
Tomcat服務器的類加載架構
- Java Web服務器:部署在同一個服務器上的兩個Web應用程序所使用的Java類庫可以實現相互隔離又要可以互相共享;盡可能保證自身的安全不受部署的Web應用程序影響;要支持JSP生成類的熱替換;
- 上圖中,灰色背景的三個類加載器是JDK默認提供的類加載器,而CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader是Tomcat自己定義的類加載器,分別加載/common/(可被Tomcat和Web應用共用)、/server/(可被Tomcat使用)、/shared/(可被Web應用使用)和/WebApp/WEB-INF/(可被當前Web應用使用)中的Java類庫,Tomcat 6.x把前面三個目錄默認合并到一起變成一個/lib目錄(作用同原先的common目錄);
9.2.2 OSGI:靈活的類加載架構
OSGI的類加載架構
- OSGI的每個模塊稱為Bundle,可以聲明它所依賴的Java Package(通過Import-Package描述),也可以聲明它允許導出發布的Java Package(通過Export-Package描述);
- 除了更精確的模塊劃分和可見性控制外,引入OSGI的另外一個重要理由是基于OSGI的程序很可能可以實現模塊級的熱插拔功能;
- OSGI的類加載器之間只有規則,沒有固定的委派關系;加載器之間的關系更為復雜、運行時才能確定的網狀結構,提供靈活性的同時,可能會產生許多的隱患;
9.2.3 字節碼生成技術與動態代理的實現
- 在Java里面除了javac和字節碼類庫外,使用字節碼生成的例子還有Web服務器中的JSP編譯器、編譯時植入的AOP框架和很常用的動態代理技術等,這里選擇其中相對簡單的動態代理來看看字節碼生成技術是如何影響程序運作的;
- 動態代理的優勢在于實現了在原始類和接口還未知的時候就確定類的代理行為,可以很靈活地重用于不同的應用場景之中;
- 以下的例子中生成的代理類“$Proxy0.class”文件可以看到代理為傳入接口的每一個方法統一調用了InvocationHandler對象的invoke方法;其生成代理類的字節碼大致過程其實就是根據Class文件的格式規范去拼接字節碼;
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("Hello world");
}
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
// add this property to generate proxy class file
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}
9.2.4 Retrotranslator:跨越JDK版本
- Retrotranslator的作用是將JDK 1.5編譯出來的Class文件轉變為可以在JDK 1.4或JDK 1.3部署的版本,它可以很好地支持自動裝箱、泛型、動態注解、枚舉、變長參數、遍歷循環、靜態導入這些語法特性,甚至還可以支持JDK 1.5中新增的集合改進、并發包以及對泛型、注解等的反射操作;
- JDK升級通常包括四種類型:編譯器層面的做的改進、Java API的代碼增強、需要再字節碼中進行支持的活動以及虛擬機內部的改進,Retrotranslator只能模擬前兩類,第二類通過獨立類庫實現,第一類則通過ASM框架直接對字節碼進行處理;
9.3 實戰:自己動手實現遠程執行功能
- 目標:不依賴JDK版本、不改變原有服務端程序的部署,不依賴任何第三方類庫、不侵入原有程序、臨時代碼的執行結果能返回到客戶端;
- 思路:如何編譯提交到服務器的Java代碼(客戶端編譯好上傳Class文件而不是Java代碼)、如何執行編譯之后的Java代碼(要能訪問其他類庫,要能卸載)、如何收集Java代碼的執行結果(在執行的類中把System.out的符號引用替換為我們準備的PrintStream的符號引用);
- 具體實現:HotSwapClassLoader用于實現同一個類的代碼可以被多次加載,通過公開父類ClassLoader的defineClass實現;HackSystem是為了替換java.lang.System,它直接修改Class文件格式的byte[]數組中的常量池部分,將常量池中指定內容的CONSTANT_Utf8_info常量替換為新的字符串;ClassModifier涉及對byte[]數組操作的部分,主要是將byte[]與int和String互相轉換,以及把對byte[]數據的替換操作封裝在ByteUtils類中;經過ClassModifier處理過的byte[]數組才會傳給HotSwapClassLoader.loadByte方法進行類加載;而JavaClassExecutor是提供給外部調用的入口;
public class JavaClassExecutor {
public static String execute(byte[] classByte) {
HackSystem.clearBuffer();
ClassModifier cm = new ClassModifier(classByte);
byte[] modifiedBytes = cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem");
HotSwapClassLoader hotSwapClassLoader = new HotSwapClassLoader();
Class clazz = hotSwapClassLoader.loadByte(modifiedBytes);
try {
Method method = clazz.getMethod("main", new Class[]{String[].class});
method.invoke(null, new String[]{null});
} catch (Throwable t) {
t.printStackTrace(HackSystem.out);
}
return HackSystem.getBufferString();
}
}
用于測試的JSP
<%@page import="java.lang.*" %>
<%@page import="java.io.*" %>
<%@page import="org.fenixsoft.classloading.execute.*" %>
<%
InputStream is = new FileInputStream("c:/TestClass.class");
byte[] b = new byte[is.available()];
is.read(b);
is.close();
out.println(JavaClassExecutor.execute(b));
%>
9.4 本章小結
只有了解虛擬機如何執行程序,才能更好地理解怎樣寫出優秀的代碼。
系列讀書筆記
- 《深入理解Java虛擬機》讀書筆記1:Java技術體系、Java內存區域和內存溢出異常
- 《深入理解Java虛擬機》讀書筆記2:垃圾收集器與內存分配策略
- 《深入理解Java虛擬機》讀書筆記3:虛擬機性能監控與調優實戰
- 《深入理解Java虛擬機》讀書筆記4:類文件結構
- 《深入理解Java虛擬機》讀書筆記5:類加載機制與字節碼執行引擎
- 《深入理解Java虛擬機》讀書筆記6:程序編譯與代碼優化
- 《深入理解Java虛擬機》讀書筆記7:高效并發
掃一掃 關注我的微信公眾號