「入門篇」初識JVM
記錄於 2022-01-02 17:25:12 ©GhostFace
1. 什麼是JVM?
概念
來自百度百科
引入Java語言虛擬機後,Java語言在不同平台上運行時不需要重新編譯。Java語言使用Java虛擬機屏蔽了與具體平台相關的資訊,使得Java語言編譯程式只需生成在Java虛擬機上運行的目標程式碼(位元組碼),就可以在多種平台上不加修改地運行。Java虛擬機有自己完善的硬體架構,如處理器、堆棧等,還具有相應的指令系統。Java虛擬機本質上就是一個程式,當它在命令行上啟動的時候,就開始執行保存在某位元組碼文件中的指令。Java語言的可移植性正是建立在Java虛擬機的基礎上。任何平台只要裝有針對於該平台的Java虛擬機,位元組碼文件(.class)就可以在該平台上運行。這就是「一次編譯,多次運行」。Java虛擬機不僅是一種跨平台的軟體,而且是一種新的網路計算平台。該平台包括許多相關的技術,如符合開放介面標準的各種API、優化技術等。Java技術使同一種應用可以運行在不同的平台上。Java平台可分為兩部分,即Java虛擬機(Java virtual machine,JVM)和Java API類庫。
- JVM是一種規範的、跨平台的軟體,一種新的網路計算平台,本質上就是一個程式,通過模擬模擬各種電腦功能實現。
- JVM可以讓Java語言在不同平台上運行時不需要重新編譯 —— Java語言的特點:跨平台、可移植性
- (.java) → Java語言編譯程式 → 在Java虛擬機上運行的目標程式碼 (.class位元組碼文件) —— 「一次編譯,多次運行」
- Java虛擬機有自己完善的硬體架構,如處理器、堆棧等,還具有相應的指令系統。
總結: JVM是一種規範,它實現了Java的跨平台,是軟體、網路計算平台或者說是程式,一個通過模擬各種電腦功能實現的程式。因為是程式,所以它有完善的架構,如處理器、堆棧、指令庫等等。
跨平台:*.java 源程式碼文件經過編譯器生成 *.class 位元組碼文件,通過這個 *.class 位元組碼文件拿到各種裝有JDK或者JRE的作業系統中執行就能運行了。
思考問題?
- *.java 源程式碼文件 如何編譯成 *.class 位元組碼文件?什麼是位元組碼文件 (.class)?
- JVM 如何將 .class 位元組碼文件變成不同作業系統可以直接運行的機器語言?
- JVM 能幹嘛?在實際開發中的運用?
- JVM 的體系結構?位置?
接下來就帶著這些問題進一步探究吧~
1. *.java 源程式碼文件 → *.class 位元組碼文件的過程?什麼是位元組碼文件?
java源文件轉換為位元組碼文件的過程
*.java 源程式碼文件 經過 javac編譯 成 *.class 位元組碼文件,這裡給出流程圖(圖1),詳細分析下次再細說,或者可以看看《深入理解Java虛擬機》原文第10章 (P456) [1]
編譯過程大致可以分為3個過程(以Sun Javac為例)
1、解析與填充符號表過程;
解析主要包括詞法分析和語法分析兩個過程
2、插入式註解處理器的註解處理過程;
3、語義分析與位元組碼的生成過程。 ©GhostFace
圖1 .java源程式碼文件到.class位元組碼文件的過程
.java源程式碼文件
通過javac編譯器先進行解析,即對詞法和語法的分析,詞法分析會將源程式碼轉換為字元流,生成標記集合(標記集合包括:關鍵字、變數名、字面量和運算符等),而語法分析是將標記集合生成語法樹的過程,(如果源程式碼中存在註解,則通過註解處理器進行處理)再進行下一步語義分析對源程式碼上下文有關性質的審查和數據及控制流分析,最後由位元組碼生成器生成.class位元組碼文件
什麼是位元組碼文件?
來自CSDN社區 理解
Java位元組碼類文件(.class)是Java編譯器編譯Java源文件(.java)產生的「目標文件」。
它是一種8位位元組的二進位流文件, 各個數據項按順序緊密的從前向後排列, 相鄰的項之間沒有間隙, 這樣可以使得class文件非常緊湊, 體積輕巧, 可以被JVM快速的載入至記憶體, 並且佔據較少的記憶體空間(方便於網路的傳輸)。
Java源文件在被Java編譯器編譯之後, 每個類(或者介面)都單獨佔據一個class文件, 並且類中的所有資訊都會在class文件中有相應的描述, 由於class文件很靈活, 它甚至比Java源文件有著更強的描述能力。
.class 位元組碼文件 由javac編譯產生,是8位位元組的二進位流文件。
特點:文件數據項緊湊,體積輕巧,佔用記憶體空間小,便於網路傳輸
Class文件的結構屬性
在理解之前先從整體看下java位元組碼文件包含了哪些類型的數據:
從一個例子開始
下面以一個簡單的例子來逐步講解位元組碼。
//Main.java
public class Main {
private int m;
public int inc() {
return m + 1;
}
}
通過以下命令, 可以在當前所在路徑下生成一個Main.class文件。
javac Main.java
以文本的形式打開生成的class文件,內容如下:
查看程式碼
cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4d61 696e 2e6a 6176 610c
0007 0008 0c00 0500 0601 0010 636f 6d2f
7268 7974 686d 372f 4d61 696e 0100 106a
6176 612f 6c61 6e67 2f4f 626a 6563 7400
2100 0300 0400 0000 0100 0200 0500 0600
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0003 0001
000b 000c 0001 0009 0000 001f 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0100
0a00 0000 0600 0100 0000 0800 0100 0d00
0000 0200 0e
- 文件開頭的4個位元組(“cafe babe”)稱之為
魔數
,唯有以”cafe babe”開頭的class文件方可被虛擬機所接受,這4個位元組就是位元組碼文件的身份識別。 - 0000是編譯器jdk版本的次版本號0,0034轉化為十進位是52,是主版本號,java的版本號從45開始,除1.0和1.1都是使用45.x外,以後每升一個大版本,版本號加一。也就是說,編譯生成該class文件的jdk版本為1.8.0。
通過java -version命令稍加驗證, 可得結果。
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)
繼續往下是常量池… 知道是這麼分析的就可以了,然後我們通過工具反編譯位元組碼文件繼續去看。
反編譯位元組碼文件
使用到java內置的一個反編譯工具javap可以反編譯位元組碼文件, 用法:
javap <options> <classes>
其中<options>
選項包括:
查看程式碼
-help --help -? 輸出此用法消息
-version 版本資訊
-v -verbose 輸出附加資訊
-l 輸出行號和本地變數表
-public 僅顯示公共類和成員
-protected 顯示受保護的/公共類和成員
-package 顯示程式包/受保護的/公共類
和成員 (默認)
-p -private 顯示所有類和成員
-c 對程式碼進行反彙編
-s 輸出內部類型簽名
-sysinfo 顯示正在處理的類的
系統資訊 (路徑, 大小, 日期, MD5 散列)
-constants 顯示最終常量
-classpath <path> 指定查找用戶類文件的位置
-cp <path> 指定查找用戶類文件的位置
-bootclasspath <path> 覆蓋引導類文件的位置
輸入命令javap -verbose -p Main.class
查看輸出內容:
查看程式碼
Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class
Last modified 2018-4-7; size 362 bytes
MD5 checksum 4aed8540b098992663b7ba08c65312de
Compiled from "Main.java"
public class com.rhythm7.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#4 = Class #21 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/rhythm7/Main;
#14 = Utf8 inc
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 Main.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
#21 = Utf8 java/lang/Object
{
private int m;
descriptor: I
flags: ACC_PRIVATE
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/rhythm7/Main;
}
SourceFile: "Main.java"
位元組碼文件資訊
開頭的7行資訊包括:Class文件當前所在位置,最後修改時間,文件大小,MD5值,編譯自哪個文件,類的全限定名,jdk次版本號,主版本號。
然後緊接著的是該類的訪問標誌:ACC_PUBLIC, ACC_SUPER,訪問標誌的含義如下:
標誌名稱 | 標誌值 | 含義 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否為Public類型 |
ACC_FINAL | 0x0010 | 是否被聲明為final,只有類可以設置 |
ACC_SUPER | 0x0020 | 是否允許使用invokespecial位元組碼指令的新語義. |
ACC_INTERFACE | 0x0200 | 標誌這是一個介面 |
ACC_ABSTRACT | 0x0400 | 是否為abstract類型,對於介面或者抽象類來說,次標誌值為真,其他類型為假 |
ACC_SYNTHETIC | 0x1000 | 標誌這個類並非由用戶程式碼產生 |
ACC_ANNOTATION | 0x2000 | 標誌這是一個註解 |
ACC_ENUM | 0x4000 | 標誌這是一個枚舉 |
常量池
Constant pool
意為常量池。
常量池可以理解成Class文件中的資源倉庫。主要存放的是兩大類常量:字面量(Literal)和符號引用(Symbolic References)。字面量類似於java中的常量概念,如文本字元串,final常量等,而符號引用則屬於編譯原理方面的概念,包括以下三種:
- 類和介面的全限定名(Fully Qualified Name)
- 欄位的名稱和描述符號(Descriptor)
- 方法的名稱和描述符
不同於C/C++, JVM是在載入Class文件的時候才進行的動態鏈接,也就是說這些欄位和方法符號引用只有在運行期轉換後才能獲得真正的記憶體入口地址。當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建或運行時解析並翻譯到具體的記憶體地址中。 直接通過反編譯文件來查看位元組碼內容:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#4 = Class #21 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#18 = NameAndType #7:#8 // "<init>":()V
#21 = Utf8 java/lang/Object
第一個常量是一個方法定義,指向了第4和第18個常量。以此類推查看第4和第18個常量。最後可以拼接成第一個常量右側的注釋內容:
java/lang/Object."<init>":()V
這段可以理解為該類的實例構造器的聲明,由於Main類沒有重寫構造方法,所以調用的是父類的構造方法。此處也說明了Main類的直接父類是Object。 該方法默認返回值是V, 也就是void,無返回值。
第二個常量同理可得:
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#5 = Utf8 m
#6 = Utf8 I
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
複製程式碼此處聲明了一個欄位m,類型為I, I即是int類型。關於位元組碼的類型對應如下:
標識字元 | 含義 |
---|---|
B | 基本類型byte |
C | 基本類型char |
D | 基本類型double |
F | 基本類型float |
I | 基本類型int |
J | 基本類型long |
S | 基本類型short |
Z | 基本類型boolean |
V | 特殊類型void |
L | 對象類型,以分號結尾,如Ljava/lang/Object; |
對於數組類型,每一位使用一個前置的[
字元來描述,如定義一個java.lang.String[][]
類型的維數組,將被記錄為[[Ljava/lang/String;
方法表集合
在常量池之後的是對類內部的方法描述,在位元組碼中以表的集合形式表現,暫且不管位元組碼文件的16進位文件內容如何,我們直接看反編譯後的內容。
private int m;
descriptor: I
flags: ACC_PRIVATE
此處聲明了一個私有變數m,類型為int,返回值為int
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
這裡是構造方法:Main(),返回值為void, 公開方法。
code內的主要屬性為:
-
stack: 最大操作數棧,JVM運行時會根據這個值來分配棧幀(Frame)中的操作棧深度,此處為1
-
locals: 局部變數所需的存儲空間,單位為Slot, Slot是虛擬機為局部變數分配記憶體時所使用的最小單位,為4個位元組大小。方法參數(包括實例方法中的隱藏參數this),顯示異常處理器的參數(try catch中的catch塊所定義的異常),方法體中定義的局部變數都需要使用局部變數表來存放。值得一提的是,locals的大小並不一定等於所有局部變數所佔的Slot之和,因為局部變數中的Slot是可以重用的。
-
args_size: 方法參數的個數,這裡是1,因為每個實例方法都會有一個隱藏參數this
-
attribute_info: 方法體內容,0,1,4為位元組碼”行號”,該段程式碼的意思是將第一個引用類型本地變數推送至棧頂,然後執行該類型的實例方法,也就是常量池存放的第一個變數,也就是注釋里的”java/lang/Object.””😦)V”, 然後執行返回語句,結束方法。
-
LineNumberTable: 該屬性的作用是描述源碼行號與位元組碼行號(位元組碼偏移量)之間的對應關係。可以使用 -g:none 或-g:lines選項來取消或要求生成這項資訊,如果選擇不生成LineNumberTable,當程式運行異常時將無法獲取到發生異常的源碼行號,也無法按照源碼的行數來調試程式。
-
LocalVariableTable: 該屬性的作用是描述幀棧中局部變數與源碼中定義的變數之間的關係。可以使用 -g:none 或 -g:vars來取消或生成這項資訊,如果沒有生成這項資訊,那麼當別人引用這個方法時,將無法獲取到參數名稱,取而代之的是arg0, arg1這樣的佔位符。 start 表示該局部變數在哪一行開始可見,length表示可見行數,Slot代表所在幀棧位置,Name是變數名稱,然後是類型簽名。
同理可以分析Main類中的另一個方法”inc()”:
方法體內的內容是:將this入棧,獲取欄位#2並置於棧頂, 將int類型的1入棧,將棧內頂部的兩個數值相加,返回一個int類型的值。
類名
最後很顯然是源碼文件:
SourceFile: "Main.java"
2. JVM 如何將 .class 位元組碼文件變成不同作業系統可以直接運行的機器語言?
從.java源程式碼經過javac編譯器編譯成.class位元組碼文件我們已經有大致印象了,接下來就需要了解class位元組碼文件如何經過JVM變成不同作業系統可以直接運行的機器語言了。直接上圖
.class文件載入到記憶體(JVM整個過程都在記憶體中執行),對數據進行 載入(Loading) → 連接(Linking) [ 驗證(Verification) → 準備(Preparation) → 解析(Resolution) ]→ 初始化(Initialization) → 使用(Using) → 卸載(Unloading) 7個階段(生命周期) ,其中 驗證、準備、解析 統稱 連接,這個過程就是類載入過程。
注意:類載入過程 ≠ 載入(Loading) ,載入是一個類載入的子環節。
類載入機制
類的生命周期
載入
、驗證
、準備
、解析
、初始化
五個階段。在這五個階段中,載入
、驗證
、準備
和初始化
這四個階段發生的順序是確定的,而解析
階段則不一定,它在某些情況下可以在初始化階段之後開始,這是為了支援Java語言的運行時綁定(也成為動態綁定或晚期綁定)。另外注意這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或激活另一個階段。
類的載入: 查找並載入類的二進位數據
載入時類載入過程的第一個階段,在載入階段,虛擬機需要完成以下三件事情:
- 通過一個類的全限定名來獲取其定義的二進位位元組流。
- 將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
- 在Java堆中生成一個代表這個類的java.lang.Class對象,作為對方法區中這些數據的訪問入口。
相對於類載入的其他階段而言,載入階段(準確地說,是載入階段獲取類的二進位位元組流的動作)是可控性最強的階段,因為開發人員既可以使用系統提供的類載入器來完成載入,也可以自定義自己的類載入器來完成載入。
載入階段完成後,虛擬機外部的 二進位位元組流就按照虛擬機所需的格式存儲在方法區之中,而且在Java堆中也創建一個java.lang.Class
類的對象,這樣便可以通過該對象訪問方法區中的這些數據。
類載入器並不需要等到某個類被「首次主動使用」時再載入它,JVM規範允許類載入器在預料某個類將要被使用時就預先載入它,如果在預先載入的過程中遇到了.class文件缺失或存在錯誤,類載入器必須在程式首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程式主動使用,那麼類載入器就不會報告錯誤。
載入.class文件的方式
- 從本地系統中直接載入
- 通過網路下載.class文件
- 從zip,jar等歸檔文件中載入.class文件
- 從專有資料庫中提取.class文件
- 將Java源文件動態編譯為.class文件
連接
驗證: 確保被載入的類的正確性
驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的位元組流中包含的資訊符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。驗證階段大致會完成4個階段的檢驗動作:
-
文件格式驗證
: 驗證位元組流是否符合Class文件格式的規範;例如: 是否以0xCAFEBABE
開頭、主次版本號是否在當前虛擬機的處理範圍之內、常量池中的常量是否有不被支援的類型。 -
元數據驗證
: 對位元組碼描述的資訊進行語義分析(注意: 對比javac
編譯階段的語義分析),以保證其描述的資訊符合Java語言規範的要求;例如: 這個類是否有父類(除了java.lang.Object
之外)、是否是抽象類,是否實現了父類或介面的方法等。 -
位元組碼驗證
: 通過數據流和控制流分析,確定程式語義是合法的、符合邏輯的。對類方法體進行校驗分析,保證被校驗類的方法在運行時不會做危害虛擬機安全的事件 -
符號引用驗證
: 確保解析動作能正確執行。對類自身以外的資訊進行匹配性校驗
驗證階段是非常重要的,但不是必須的,它對程式運行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用
-Xverify:none
參數來關閉大部分的類驗證措施,以縮短虛擬機類載入的時間。
準備: 為類的靜態變數分配記憶體,並將其初始化為默認值
準備階段是正式為類變數分配記憶體並設置類變數初始值的階段,這些記憶體都將在方法區中分配。對於該階段有以下幾點需要注意:
-
這時候進行記憶體分配的僅包括類變數(
static
),而不包括實例變數,實例變數會在對象實例化時隨著對象一塊分配在Java堆中。 -
這裡所設置的初始值通常情況下是數據類型默認的零值(如
0
、0L
、null
、false
等),而不是被在Java程式碼中被顯式地賦予的值。
數據類型 | 默認零值 | 數據類型 | 默認零值 |
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short)0 | double | 0.0d |
char | ‘\u0000’ | reference | null |
byte | (byte)0 |
假設一個類變數的定義為: public static int value = 3
;那麼變數value在準備階段過後的初始值為0
,而不是3
,因為這時候尚未開始執行任何Java方法,而把value賦值為3的put static
指令是在程式編譯後,存放於類構造器<clinit>()
方法之中的,所以把value賦值為3的動作將在初始化階段才會執行。
這裡還需要注意如下幾點
- 對基本數據類型來說,對於類變數(static)和全局變數,如果不顯式地對其賦值而直接使用,則系統會為其賦予默認的零值,而對於局部變數來說,在使用前必須顯式地為其賦值,否則編譯時不通過。
- 對於同時被
static
和final
修飾的常量,必須在聲明的時候就為其顯式地賦值,否則編譯時不通過;而只被final修飾的常量則既可以在聲明時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統不會為其賦予默認零值。 - 對於引用數據類型
reference
來說,如數組引用、對象引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會為其賦予默認的零值,即null
。 - 如果在數組初始化時沒有對數組中的各元素賦值,那麼其中的元素將根據對應的數據類型而被賦予默認的零值。
- 如果類欄位的欄位屬性表中存在ConstantValue屬性,即同時被final和static修飾,那麼在準備階段變數value就會被初始化為ConstValue屬性所指定的值。假設上面的類變數value被定義為:
public static final int value = 3;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值為3。我們可以理解為static final
常量在編譯期就將其結果放入了調用它的類的常量池中
解析: 把類中的符號引用轉換為直接引用
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,解析動作主要針對類
或介面
、欄位
、類方法
、介面方法
、方法類型
、方法句柄
和調用點
限定符7類符號引用進行。
符號引用
就是一組符號來描述目標,可以是任何字面量。直接引用
就是直接指向目標的指針、相對偏移量或一個間接定位到目標的句柄。
初始化
初始化,為類的靜態變數賦予正確的初始值,JVM負責對類進行初始化,主要對類變數進行初始化。在Java中對類變數進行初始值設定有兩種方式:
- 聲明類變數是指定初始值
- 使用靜態程式碼塊為類變數指定初始值
JVM初始化步驟
- 假如這個類還沒有被載入和連接,則程式先載入並連接該類
- 假如該類的直接父類還沒有被初始化,則先初始化其直接父類
- 假如類中有初始化語句,則系統依次執行這些初始化語句
類初始化時機: 只有當對類的主動使用的時候才會導致類的初始化,類的主動使用包括以下六種:
- 創建類的實例,也就是new的方式
- 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
- 調用類的靜態方法
- 反射(如Class.forName(“com.pdai.jvm.Test”))
- 初始化某個類的子類,則其父類也會被初始化
- Java虛擬機啟動時被標明為啟動類的類(Java Test),直接使用java.exe命令來運行某個主類
使用
類訪問方法區內的數據結構的介面, 對象是Heap區的數據。
卸載
Java虛擬機將結束生命周期的幾種情況
- 執行了System.exit()方法
- 程式正常執行結束
- 程式在執行過程中遇到了異常或錯誤而異常終止
- 由於作業系統出現錯誤而導致Java虛擬機進程終止
類載入器, JVM類載入機制
類載入器的層次
注意: 這裡父類載入器並不是通過繼承關係來實現的,而是採用組合實現的。
站在Java虛擬機的角度來講,只存在兩種不同的類載入器: 啟動類載入器: 它使用C++實現(這裡僅限於
Hotspot
,也就是JDK1.5之後默認的虛擬機,有很多其他的虛擬機是用Java語言實現的),是虛擬機自身的一部分;所有其他的類載入器: 這些類載入器都由Java語言實現,獨立於虛擬機之外,並且全部繼承自抽象類java.lang.ClassLoader
,這些類載入器需要由啟動類載入器載入到記憶體中之後才能去載入其他的類。
站在Java開發人員的角度來看,類載入器可以大致劃分為以下三類 :
啟動類載入器
: Bootstrap ClassLoader,負責載入存放在JDK\jre\lib(JDK代表JDK的安裝目錄,下同)下,或被-Xbootclasspath
參數指定的路徑中的,並且能被虛擬機識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader載入)。啟動類載入器是無法被Java程式直接引用的。
擴展類載入器
: Extension ClassLoader,該載入器由sun.misc.Launcher$ExtClassLoader
實現,它負責載入JDK\jre\lib\ext目錄中,或者由java.ext.dirs系統變數指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴展類載入器。
應用程式類載入器
: Application ClassLoader,該類載入器由sun.misc.Launcher$AppClassLoader
來實現,它負責載入用戶類路徑(ClassPath)所指定的類,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中默認的類載入器。
應用程式都是由這三種類載入器互相配合進行載入的,如果有必要,我們還可以加入自定義的類載入器。因為JVM自帶的ClassLoader只是懂得從本地文件系統載入標準的java class文件,因此如果編寫了自己的ClassLoader,便可以做到如下幾點:
- 在執行非置信程式碼之前,自動驗證數字簽名。
- 動態地創建符合用戶特定需要的訂製化構建類。
- 從特定的場所取得java class,例如資料庫中和網路中。
尋找類載入器
尋找類載入器小例子如下:
package com.pdai.jvm.classloader;
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println(loader);
System.out.println(loader.getParent());
System.out.println(loader.getParent().getParent());
}
}
結果如下:
sun.misc.Launcher$AppClassLoader@64fef26a
sun.misc.Launcher$ExtClassLoader@1ddd40f3
null
從上面的結果可以看出,並沒有獲取到ExtClassLoader
的父Loader,原因是BootstrapLoader
(引導類載入器)是用C語言實現的,找不到一個確定的返回父Loader的方式,於是就返回null
。
類的載入
類載入有三種方式:
1、命令行啟動應用時候由JVM初始化載入
2、通過Class.forName()方法動態載入
3、通過ClassLoader.loadClass()方法動態載入
查看程式碼
package com.pdai.jvm.classloader;
public class loaderTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader loader = HelloWorld.class.getClassLoader();
System.out.println(loader);
//使用ClassLoader.loadClass()來載入類,不會執行初始化塊
loader.loadClass("Test2");
//使用Class.forName()來載入類,默認會執行初始化塊
// Class.forName("Test2");
//使用Class.forName()來載入類,並指定ClassLoader,初始化時不執行靜態塊
// Class.forName("Test2", false, loader);
}
}
public class Test2 {
static {
System.out.println("靜態初始化塊執行了!");
}
}
分別切換載入方式,會有不同的輸出結果。
Class.forName()和ClassLoader.loadClass()區別?
- Class.forName(): 將類的.class文件載入到jvm中之外,還會對類進行解釋,執行類中的static塊;
- ClassLoader.loadClass(): 只干一件事情,就是將.class文件載入到jvm中,不會執行static中的內容,只有在newInstance才會去執行static塊。
- Class.forName(name, initialize, loader)帶參函數也可控制是否載入static塊。並且只有調用了newInstance()方法採用調用構造函數,創建類的對象 。
JVM類載入機制
-
全盤負責
,當一個類載入器負責載入某個Class時,該Class所依賴的和引用的其他Class也將由該類載入器負責載入,除非顯示使用另外一個類載入器來載入 -
父類委託
,先讓父類載入器試圖載入該類,只有在父類載入器無法載入該類時才嘗試從自己的類路徑中載入該類 -
快取機制
,快取機制將會保證所有載入過的Class都會被快取,當程式中需要使用某個Class時,類載入器先從快取區尋找該Class,只有快取區不存在,系統才會讀取該類對應的二進位數據,並將其轉換成Class對象,存入快取區。這就是為什麼修改了Class後,必須重啟JVM,程式的修改才會生效 -
雙親委派機制
, 如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把請求委託給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜索範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類。
雙親委派機制過程?
- 當AppClassLoader載入一個class時,它首先不會自己去嘗試載入這個類,而是把類載入請求委派給父類載入器ExtClassLoader去完成。
- 當ExtClassLoader載入一個class時,它首先也不會自己去嘗試載入這個類,而是把類載入請求委派給BootStrapClassLoader去完成。
- 如果BootStrapClassLoader載入失敗(例如在$JAVA_HOME/jre/lib里未查找到該class),會使用ExtClassLoader來嘗試載入;
- 若ExtClassLoader也載入失敗,則會使用AppClassLoader來載入,如果AppClassLoader也載入失敗,則會報出異常ClassNotFoundException。
雙親委派程式碼實現
查看程式碼
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized 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 {
//如果不存在父類載入器,就檢查是否是由啟動類載入器載入的類,通過調用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父類載入器和啟動類載入器都不能完成載入任務,才調用自身的載入功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
雙親委派優勢
- 系統類防止記憶體中出現多份同樣的位元組碼
- 保證Java程式安全穩定運行
自定義類載入器
通常情況下,我們都是直接使用系統類載入器。但是,有的時候,我們也需要自定義類載入器。比如應用是通過網路來傳輸 Java 類的位元組碼,為保證安全性,這些位元組碼經過了加密處理,這時系統類載入器就無法對其進行載入,這樣則需要自定義類載入器來實現。自定義類載入器一般都是繼承自 ClassLoader 類,從上面對 loadClass 方法來分析來看,我們只需要重寫 findClass 方法即可。下面我們通過一個示例來演示自定義類載入器的流程:
查看程式碼
package com.pdai.jvm.classloader;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("D:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.pdai.jvm.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定義類載入器的核心在於對位元組碼文件的獲取,如果是加密的位元組碼則需要在該類中對文件進行解密。由於這裡只是演示,我並未對class文件進行加密,因此沒有解密的過程。
這裡有幾點需要注意 :
1、這裡傳遞的文件名需要是類的全限定性名稱,即com.pdai.jvm.classloader.Test2
格式的,因為 defineClass 方法是按這種格式進行處理的。
2、最好不要重寫loadClass方法,因為這樣容易破壞雙親委託模式。
3、這類Test 類本身可以被 AppClassLoader 類載入,因此我們不能把com/pdai/jvm/classloader/Test2.class 放在類路徑下。否則,由於雙親委託機制的存在,會直接導致該類由 AppClassLoader 載入,而不會通過我們自定義類載入器來載入。
參考文章
面試題
請你談談你對JVM的理解? java8虛擬機和之前的變化更新?
JDK、JRE、JVM的關係是什麼?
JVM的常用調優參數有哪些?
記憶體快照如何抓取,怎麼分析Dump文件?知道嗎?
談談JVM中,類載入器你的認識?
引用
書籍:深入理解Java虛擬機:JVM高級特性與最佳實踐(第2版)作者:周志明 —— 以JDK7為技術平台的 提取碼:gfxl
文章:Java源程式碼編譯成位元組碼文件的過程——第10章(P456) [1]
什麼是位元組碼文件? [2]