文章目錄
JVM(java虛擬機)
JVM隔離了各種操作系統與硬件平臺的差異,爲字節碼的執行提供了統一的運行環境並可以執行字節碼(如果將class文件比作可執行文件,那麼字節碼就相當於cpu指令,JVM就可以被看做是操作系統)。JVM並非只有一種,有多家公司都發布了自己的虛擬機軟件,他們皆可以用來運行字節碼,其中最出名的是HotSpot虛擬機。當虛擬機運行時就像是運行在宿主機上的一個普通進程。
Java編譯有三種形式,前端編譯、即時編譯(JIT編譯)、靜態提前編譯(AOT編譯)。前端編譯是將源文件編譯爲字節碼class文件;即時編譯是在運行時將字節碼編譯爲本地機器碼;靜態提前編譯是直接將源文件編譯成本地機器碼。相較靜態提前編譯,即時編譯需要在運行時額外佔用cpu資源,但運行時能夠獲得更多的信息,所以即時編譯的優化效果比較好,因此大多數的優化都發生在即時編譯。HotSpot運行時一般是即時編譯執行熱點代碼(虛擬機參數-XX:CompileThreshold=N表示當某個方法被執行N次時會被認爲是熱點代碼,熱點代碼只會編譯一次並存儲,以後直接使用),解釋執行不常用的代碼。
前端編譯將源文件編譯爲符合字節碼規範的,可在虛擬機上執行class文件。這裏的源文件不僅指符合java規範的java文件,也可能是符合其他語言規範的文件,如Clojure、JRuby、Groovy等,只是需要針對不同的語言使用不同的前端編譯器,如果有合適的編譯器,我們甚至可以把C語言編譯成字節碼讓JVM去執行。可見Java規範與JVM規範是兩個不同的範疇,如果我們對字節碼工程足夠熟悉,甚至可以直接編輯出JVM可識別的class文件。
JVM運行時內存區域
Java與C++之間有一堵有內存動態分配與垃圾回收計算所圍成的高牆,牆外面的人想進去,強裏面的人想出來。
每一個進程都有自己的進程空間,32位系統的進程空間大小爲232Byte,64位系統的進程空間大小爲264Byte,進程中指令的地址和數據存儲的地址都是進程空間中的地址,每一個進程空間都映射到一段物理內存,每一個進程中的地址都通過MMU訪問到實際內存對應的空間。
每一個進程空間都分爲內核空間和用戶空間,所有進程的內核空間都映射到操作系統內核(所以內核空間可以用作進程間通信的橋樑),用戶空間負責保存本進程運行的代碼和數據,一般被分爲代碼段、數據段、堆和棧,但實際上分不分段以及怎麼分段都無所謂,分段只是爲了使程序更加優美,比如某個指令需要訪問一個地址,該地址可以處於任何區域。
通過java命令啓動一個虛擬機進程,其進程空間與普通進程的區域劃分如上圖。如圖可見JVM進程空間的代碼區和數據區實際上是JVM作爲一個普通進程的代碼區和數據區,不涉及到字節碼(java代碼)。普通進程創建對象會向內核申請在堆區分配空間(需要顯式釋放),而JVM進程會在啓動時一次向內核申請在堆區分配足夠的空間(在JVM進程結束時由JVM程序顯式釋放),並且這部分空間以後將由JVM進程自己管理,所以Java需要創建對象時向JVM進程申請在該區域的部分空間,此處申請不需再經過內核,而該部分內存的釋放也不需要顯式釋放(實際上並未釋放給操作系統,而是釋放給JVM),由虛擬機自己實現GC,可見JVM的堆實際上使用的是池化技術。
JVM啓動後會向內核申請一部分進程空間由JVM管理,這部分空間在JVM進程結束後釋放,JVM把管理的內存被劃分爲若干個不同的數據區域,程序計算器、虛擬機棧、本地方法棧、堆與方法區,其中程序計算器、虛擬機棧、本地方法棧是線程私有的,每一個線程都有一個,堆和方法區則是所有線程共享的。在內存分配時可能會出現內存溢出OOM(OutOfMemory)異常。
程序計數器
程序計數器可以看作是當前線程所執行的字節碼的行號指示器(當執行native方法時,其值爲undefined),每一個線程都有一個程序計數器(N個線程的程序計數器佔用的內存大小爲 N * 程序計數器區大小),該區域是唯一個一個不會出現內存溢出的區域。
虛擬機棧
虛擬機棧也是線程私有的,也就是每一個線程都有一個自己的棧,在創建線程時就分配了其初始化棧大小的空間,通過-Xss設置棧大小(N個線程的虛擬機棧佔用的內存大小爲 N * Xss的值),棧是由許多棧幀組成,棧幀對應的是一個方法,當在線程中發生一次方法調用時就會有新的棧幀入棧,當方法返回時就會有棧幀出棧。每一個棧幀包含了局部變量表、操作數棧、動態鏈接、方法出口等信息,在編譯程序代碼時,需要多大的局部變量表,多深的操作數棧都已經確定並保存在方法表的Code屬性中,因此進入一個方法時,其棧幀大小就已經分配不會再更改。棧會拋出StackOverflowError(棧大小Xss無法容納當前線程新壓入的棧幀時,比如無法結束的遞歸)和OutOfMemoryError(當新創建線程申請該線程的棧空間時,進程空間或物理內存不足)異常。
局部變量表存放了編譯期可知的各種基本數據類型和對象引用的局部變量,局部變量表所需的內存空間在編譯期間確定,當生成一個棧幀時,局部變量表的大小已經定死,之後不會改變。成員變量的基本類型和對象引用存放在堆裏,因爲一個堆中的對象佔用內存的主要部分就是其成員變量(基本變量保存數據,引用對象指向堆中的另一個對象)。局部變量單位爲Slot(4個字節),long和double佔用兩個Slot,boolean、byte、char、short、int、float、reference和returnAddress佔用1個Slot,reference爲引用類型,returnAddress是早期提供給jsr、jsr_w和ret指令使用的跳轉地址,但現在已經用異常處理表替換了這幾條指令,所以returnAddress也開始滅絕了。
操作數棧既然是棧那也是通過出棧和入棧來處理,它是當前方法中運算過程中的臨時變量的存儲地。
// 編譯前
public void func() {
int a = 5 + 10;
int b = a + 3;
b = b + 6;
}
// 反編譯後代碼如下,在進入方法後就在局部變量表裏面分配了兩個空間索引爲1和2分別對應變量a和b
bipush 15 // 將15壓入操作數棧(編譯過程中5+10合併成15) ,此時操作數棧爲15
istore_1 // 從操作數棧彈出棧頂15存儲到局部變量表索引爲1的空間,此時操作數棧爲空
iload_1 // 取出局部變量表中訪問索引爲1的空間並重新壓入棧頂,此時操作數棧爲15
iconst_3 // 將數值3壓入操作數的棧頂,此時操作數棧爲15、3
iadd // 將棧頂的前兩個彈出並進行加法運算後將結果重新壓入棧頂,此時操作數棧爲18
istore_2 // 從棧頂彈出18並壓入局部變量表訪問索引爲2的空間,此時操作數棧爲空
iload_2 // 將局部變量表中訪問索引爲2的Slot重新壓入棧頂,此時操作數棧爲18
bipush 6 // 將6壓入操作數棧,此時操作數棧爲18、6
iadd // 將棧頂的前兩個彈出並進行加法運算後將結果重新壓入棧頂,此時操作數棧爲24
istore_2 // 從棧頂彈出24並壓入局部變量表訪問索引爲2的空間,此時操作數棧爲空
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用, 持有這個引用是爲了支持方法調用過程中的動態連接。
方法返回地址,當調用一個方法時會在當前線程的棧頂創建一個代表被調用方法的棧幀,而調用方法當前指令的位置會被保存在被調用方法的棧幀中,當被調用方法執行完成後,它的棧幀被彈出,調用方法的棧幀重新作爲棧頂,當前線程的PC寄存器回到調用方法的原指令的下一條指令。
本地方法棧
本地方法棧( Native Method Stack) 與虛擬機棧所發揮的作用是非常相似的, 它們之間的區別不過是虛擬機棧爲虛擬機執行Java方法( 也就是字節碼) 服務, 而本地方法棧則爲虛擬機使用到的Native方法服務。設置本地方法棧大小的參數爲-Xoss,在HotSpot中不區分本地方法棧與虛擬機棧,本地方法棧的棧幀在虛擬機棧中和普通的棧幀無異,所以在HotSpot中設置-Xoss參數沒有任何意義。
堆
堆是JVM所管理的內存中最大的一塊,幾乎所有的對象實例都在這裏分配內存,它是所有線程都共享的內存區域。在JVM啓動時就向內核申請了初始化大小的堆空間(-Xmx參數設置,堆空間不一定是進程空間中連續的地址),當堆不夠用時(GC後仍不夠用)堆會自動進行擴展,當堆的內存不夠用並且已經無法擴展時(通過-Xms參數設置了擴展上限或者物理內存不夠用,或者進程空間不夠用),會拋出OutOfMemoryError異常(附帶異常信息Java heap space),爲了避免堆擴展過程的開銷,經常把-Xmx與-Xms設置爲相同值。
堆可分爲新生代和老年代,新生代進一步被劃分爲Eden空間和兩個Survivor空間,堆的進一步劃分只是爲了更好地回收內存,或者更快地分配內存。通過-Xmn制定新生代大小,剩下的爲老年代,-XX: SurvivorRatio=N意味着Eden與一個Survivor的比例爲N。
在HotSpot中,一個對象在內存中的佈局分爲三部分,對象頭、實例數據與對齊填充。實例數據是對象真正存儲的有效信息,也就是在程序代碼中所定義的各種類型的非靜態成員變量(包括從父類繼承而來的);JVM要求對象的起始地址必須是8的整數倍,如果對象頭+實例數據所佔用的空間不是8的整數倍,那麼就需要對齊填充;對象頭分爲兩部分(數組分爲三部分),第一部分用於存儲對象自身的運行時數據(如HashCode、gc分代年齡、鎖狀態等信息),第二部分是類型指針,第三部分只有數組纔有,爲數組長度。
方法區
方法區用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。Java虛擬機規範把方法區描述爲堆的一個邏輯部分。方法區超過該值會拋出OOM異常。
class常量池、運行時常量池和String常量池。class文件中的常量池保存着字面值和符號引用,對字節碼的執行提供支持;運行時常量池位於方法區,是class文件加載到內存中的表現形式,爲字節碼的執行提供支持,每一個類都對應一個運行時常量池;String常量池實際上是一個表(也被叫着StringTable),表中保存着許多String類型實例的地址,在整個虛擬機中只有一個String常量池,Java6前String常量池被放在永久代,Java7開始String常量池被轉移至堆中,Java8開始存被放在了元空間。
讀寫String常量池的方式只有String.intern()方法與ldc指令。ldc從運行時常量池加載某個CONSTANT_String_info常量時(代碼中的String常量會產生一條操作數爲CONSTANT_String_info的ldc指令),首先會到String常量池查看是否有值爲CONSTANT_String_info的String實例,如果有就將該實例的引用壓入棧,否則會創建一個String實例(在哪個區創建本座也沒有查到資料,不過who care呢)並將其引用壓入棧且放入String常量池中。String對象的intern()方法會先查詢String常量池,如果存在值與該String對象相等的引用,那麼返回該引用,否則會將String對象的引用放入String常量池中(在Java7之前,並不是將String對象的引用放入String常量池,而是創建一個值爲該String對象值的新String對象,並將新String對象的引用放入String常量池)。示例分析如下:
void test() {
String s = new String("1"); // 1
s.intern(); // 2
String s2 = "1"; // 3
System.out.println(s == s2); // 4
String s3 = new String("1") + new String("1"); // 5
s3.intern(); // 6
String s4 = "1" + "1"; // 7 這裏等同於String s4 = "11",對於兩個String常量進行+操作,編譯器會將其合併爲一個CONSTANT_String_info常量。
System.out.println(s3 == s4); // 8
}
執行代碼1時,首先創建一個值爲"1"的String對象a並將其引用寫入String常量池,然後再創建一個值爲"1"的String對象b並將引用賦值給s;執行代碼2時,去查找String常量池中值與b的值相等的對象,找到a返回;執行代碼3時,去查找String常量池中值爲"1"的對象,找到a並將其引用賦值給s2;執行代碼4時,s與s2都指向對象a,所以兩者相等。執行代碼5時,創建值爲"1"的String對象c以及值爲"1"的String對象d,並創建值爲"11"的String對象e賦值給s3;執行代碼6時,去查找String常量池中值與e相等的對象,未找到,對於java7+,在String常量池中加入e的引用,對於java6-,克隆一個值與e相等的對象f並將其引用放入常量池;執行代碼7,查找String常量池值爲"11"的對象賦值給s4,java7+爲e,java6-爲f;執行代碼8時,s3爲e,java7+時s4爲e,java6-時s4爲f,所以java6-爲false,java7+爲true。
如果將代碼6和代碼7換個順序,那麼執行代碼7時,會新建一個值爲"11"的對象放入常量池並賦值給s4,這時候在任何java版本中都爲false。依賴於String常量池來判斷對象是否相同有太多不確定因素,且不說java版本問題,在某個模塊中,你不能確定此時String常量池中是否已有某個字符串,因爲一個項目包含了許多模塊。
運行時常量池是方法區的一部分,它用來保存常量,常量可以實現共享以節約內存。 在類加載過程中會將class文件中的常量池解析到運行時常量池(class文件中的常量池包括字面值(文本字符串、基本類型、static final常量值)與符號引用(類名、方法名、字段名))。
方法區是個邏輯概念,Java1.6之前,方法區由永久代實現,Java1.7開始將方法區中的運行時常量池從永久代遷移到了堆中,Java1.8開始徹底摒棄了永久代,運行時常量池依然存放在堆中,由元空間代替永久代實現方法區其他部分。元空間與永久代最大的區別在於,元空間並不在虛擬機中,而是使用本機內存。
在1.7之前通過-XX: PermSize可設置永久代初始化空間,通過-XX: MaxPermSize可設置永久代的空間上限,永久代超過該值會拋出OOM異常(附加異常信息PermGen space)。在1.8之後由於永久代被徹底摒棄,-XX: PermSize與-XX: MaxPermSize失效,由-XX:MetaspaceSize來控制元空間大小。
直接內存
直接內存並不是虛擬機運行時數據區的一部分,它直接向內核申請在進程空間分配內存,並不是向JVM申請空間。在一些場景中,直接內存能顯著提高性能,因爲避免了在Java堆和Native堆中來回複製數據。在進程空間或者物理內存不夠用時或者超過配置的最大內存時(通過-XX:MaxDirectMemorySize可以設置最大直接內存,默認值爲-Xmx)也會拋出OOM異常。
GC(垃圾回收)
JVM自己在需要的時候進行GC,編程人員不必手動進行內存釋放(也可以通過System.gc()手動觸發GC,但不建議使用)。
對象的死活
GC首先就須要判斷哪些對象已經死掉了需要被GC。標記活着對象最容易想到的就是引用計數算法,有地方引用該對象時計數就加1,引用失效計數就減1,計數不爲0就意味着對象還活着,該算法效率較高,但最大的弊端就是無法解決循環引用。在主流的JVM中使用可達性分析算法。
可達性分析算法通過一系列的GC Roots作爲起始點向下搜索,能夠搜索到的節點便是存活的節點。GC Roots包含哪些GC Root呢?虛擬機棧與本地方法棧的棧幀的局部變量表中引用的對象,以及方法區中靜態屬性與常量引用的對象。
引用分爲強引用、軟引用、弱引用、虛引用,強度依次減弱,在可達性分析時分別對應強可達、軟可達、弱可達、虛可達。
當一個對象直接通過"="賦值給一個引用時,這個對象就被強引用,虛擬機寧願拋出OOM異常也不會回收強可達對象,如果某個成員變量特別是靜態變量引用了其他對象,當以後不會使用這些對象時,可以選擇把變量置爲null。
軟引用是使用SoftReference創建的引用,只有在無引用(以及虛可達和弱可達)的對象回收後任然內存不足時纔會回收軟可達對象,回收軟可達對象後任然內存不足纔會拋出OOM異常。軟引用的特徵使它很適合做緩存,如網頁緩存、圖片緩存。
弱引用是使用WeakReference創建的引用,在發生GC時,只要發現弱引用,不管系統堆空間是否足夠,都會將對象進行回收。與軟引用一樣,弱引用也適合做緩存,使用頻率高,比較大的對象適合軟引用,使用頻率低,比較小的對象適合弱引用,在無法判斷使用誰時,使用WeakHashMap一般不會有問題。WeakHashMap是一個Map,不過其key對應的value對象被弱引用,value對象如果被回收了,那麼WeakHashMap對應的鍵值對也被從Map中移除了。
虛引用是使用PhantomReference創建的引用,虛引用的對象不會對垃圾回收造成影響,它的get()方法始終返回null(也就是通過虛引用無法獲取對象的句柄,這樣虛引用跟無引用就沒什麼區別)。虛引用必須與引用隊列一起使用,當虛引用的對象被回收後,該虛引用就會被放進引用隊列,這樣虛引用相較無引用可以起到獲得通知的作用,可以將一個強引用對象額外增加一個虛引用,當該對象不需要再使用時,斷開強引用,當對象回收時會獲得通知。
public abstract class Reference<T> {
Reference(T referent);
Reference(T referent, ReferenceQueue<? super T> queue); // 當對象被回收後會將該引用放入註冊的queue中
public T get(); // 返回對象的強引用(虛引用始終返回null),如果對象已經回收返回null
public void clear(); // 與referent解除引用關係,referent變爲無引用(如果referent沒有其他引用的話)
public boolean isEnqueued(); // 當前引用是否在註冊的queue中
public boolean enqueue(); // 將當前引用放入註冊的queue中
}
public class ReferenceQueue<T> {
public Reference<? extends T> poll(); // 如果隊列有引用就返回並移除下一個引用,否則返回null
public Reference<? extends T> remove(long timeout); // poll的阻塞模式,超時返回null
public Reference<? extends T> remove(); // poll的阻塞模式
}
Reference是用來保存引用的,但它同時也是一個Java對象,如果Reference引用的對象已經被釋放而引用本身無法被釋放就會出現內存泄漏。可以用一個全局集合保存所有引用,同時爲其配置一個引用隊列作爲搭檔,定時根據引用隊列中的引用移除全局集合中的引用。
垃圾回收器
垃圾回收常用的算法又標記-清除算法、複製算法、標記-整理算法、分代收集算法。標記-清除算法首先對所有的已死對象進行標記,然後對標記的對象進行清除,其缺點爲產生大量不連續空間;複製算法將容量等分爲兩塊,其中一塊是有效的,當有效的這一塊用完時,將活着的對象轉移到另一塊,然後將另一塊設爲有效,原來有效的那一塊設置爲無效並清理,其缺點爲只有一半的空間爲有效空間,並且活着對象多時複製效率較低;標記-整理算法先對所有的活着對象進行標記,然後將標記的對象移動到內存的一端,這樣解決了標記-清除算法空間不連續問題;分代收集算法根據對象存活週期的不同將內存劃分爲幾塊,各塊根據特點採用不同的算法。
HotSpot的堆GC在整體上使用的是分代收集算法,將堆區劃分爲新生代與老年代,新生代中的對象存活週期短,使用的是複製算法,老年代的對象多而且生命週期相對較長,使用的是標記算法。準確的說,新生代使用的是複製算法的變種,這是因爲新時代98%的對象生命週期都非常短,新生代被進一步劃分爲一個Eden區域兩個Survivor區(一個活躍Survivor區),向堆申請空間首先會向Eden區申請空間,若Eden區沒有足夠的空間時將觸發新生代GC(Minor GC),新生代GC將Eden區與活躍Survivor區中的存活對象移動到非活躍Survivor區(因爲此時存活的對象很少,所以Survivor區能夠容納),此時不活躍的Survivor區轉爲活躍區與清空的Eden區一起繼續提供內存服務,因爲新生代活躍對象少,Eden與Survivor的容量比例默認爲8:1,也就是說這樣的複製算法在有效容量上達到了9/10。當某一次觸發Minor GC且Survivor也無法容納所有活着的對象時,就會將新生代的對象全部移動到老年代中,若老年代也無法容納這些對象,就觸發老年代GC(Major GC或叫着Full GC),如果進行Full GC之後任然無法容納這些對象就拋出OOM異常。但如果是大對象(內存超過-XX: PretenureSizeThreshold指定的值的對象,如很多byte[])就不會向Eden區申請空間而直接向老年代申請空間,因爲大對象不適合複製算法,但如果大對象生命週期短,那麼Full GC觸發頻繁也會影響效率,故而應儘量避免使用生命週期短的大對象。每一次Minor GC時新生代中的對象年齡都會加1,當年齡達到一定閾值(-XX: MaxTenuringThreshol設置閾值,默認15)就算Survivor區尚有空間也會把這個對象放入老年代,如果某個年齡的對象佔了Survivor區內存的一半,那麼大於或等於該年齡的對象也會晉升到老年代。
在HotSpot 1.7版本後提供了幾種垃圾回收器,如下圖所示:
上圖中間分割線上面的垃圾回收器適合於新生代的垃圾回收,下面的垃圾回收器適合於老年代垃圾回收,只有相連的兩個垃圾回收器纔可以搭配使用,進行新生代和老年代的垃圾回收。根據不同的應用場景選擇不同的組合進行垃圾回收。
Serial收集器是最基本、歷史最悠久的收集器,它採用單線程進行回收,並且在回收時,其他工作線程都必須暫停工作(Stop The World簡稱STW),因爲是單線程,所以適合於單核CPU,在客戶端模式下是一個不錯的選擇。
ParNew就是Serial收集器的多線程版本,它採用多線程進行回收,在回收時,也會暫停其他工作線程,適合於多核CPU(雙核情況不一定筆Serial收集器效果好),在服務端模式下是個不錯的選擇,使用 -XX: ParallelGCThreads控制收集線程個數,默認爲CPU核心數。
Parallel Scavenge收集器也是採用多線程進行回收,在回收時,也會暫停其他工作線程。其他垃圾回收器大多關注的是停頓時間(就是每一次垃圾回收的時間),Parallel Scavenge收集器關注的卻是吞吐量(就是在一段時間內工作線程運行時間佔用的比例),停頓時間短有利於交互,高吞吐量適合於後臺運算程序。可以用-XX: MaxGCPauseMillis控制垃圾最大停頓時間(停頓時間過小會導致GC頻繁),用-XX: GCTimeRatio設置工作時間與GC時間的比例,默認99,吞度量爲1%,可以根據關注點設置這兩個參數之一。另外使用-XX: +UseAdaptiveSizePolicy參數就不需要設置-XX: SurvivorRatio和-XX: PretenureSizeThreshold,他會根據最大停頓時間與吞吐量設置自動調整。
Serial Old收集器是Serial收集器的老年代版本,也是工作在單線程STW模式下,使用標記-整理算法,適合於客戶端模式。
Parallel Old收集器是Parallel Scavenge收集器的老年版本,它工作在多線程STW模式下,使用標記-整理算法,關注“吞吐量優先”,適合於服務端模式。
CMS收集器以最短停頓時間爲目的,非常適合互聯網站或B/S服務器。CMS收集器基於標記-清除算法,收集過程分爲4個階段初始標記(STW)、併發標記、重新標記(STW)、併發清除,其中耗時最長的併發標記與併發清除都可以與用戶線程併發執行,GC線程個數默認爲(CPU個數+3) / 4個以保證GC線程留給用戶線程足夠的CPU資源。CMS常用標記-清除算法會產生碎片,收集器頂不住壓力時會進行碎片整理,此時也會STW。
G1收集器是一款面向服務端應用的垃圾收集器,它同時管理着老年代和新生代,是目前最主流的垃圾回收器。
GC日誌與參數配置
GC日誌記錄了每一次GC的信息,通過-XX: +PrintGCDetails或-verbose: gc表示需要打印GC信息,-Xloggc: filePath指定日誌存放地址,不同的GC收集器可能日誌格式小有差別,大概如下:
33. 125: [ GC[ DefNew: 3324K-> 152K( 3712K) , 0. 0025925 secs] 3324K-> 152K( 11904K) , 0. 0031680 secs]
最開始的33.125是垃圾回收時間(虛擬機啓動後的秒數);GC表示本次GC沒有STW,如果本次GC採用了STW,那麼這裏是Full GC;DefNew表示GC發生的區域(另外有Tenured、Perm);3324K->152K(3712)表示GC前該區域內存使用容量->GC後該區域內存使用容量(該內存區域總容量),3324K->152K(11904K) 表示GC前Java堆已使用容量->GC後Java堆已使用容量(Java堆總容量);0.00.1680secs表示GC使用時間 。
以下爲各種垃圾回收相關的配置參數:
參數 | 說明 |
---|---|
UseSerialGC | 使用 Serial + Serial Old 進行垃圾回收 |
UseParNewGC | 使用 ParNew + Serial Old 進行垃圾回收 |
UseConcMarkSweepGC | 使用 ParNew + CMS + Serial Old 進行垃圾回收,Serial Old作爲CMS出現Concurrent Mode Failure失敗後的備用收集器 |
UseParallelGC | 使用 Parallel Scavenge + Serial Old 進行垃圾回收 |
UseParallelOldGC | 使用 Parallel + Parallel Old 進行垃圾回收 |
SurvivorRatio | Eden區域與一個Survivor區域的比例,默認爲8 |
PretenureSizeThreshold | 直接晉升老年代的對象大小 |
MaxTenuringThreshold | 晉升老年代的年齡 |
UseAdaptiveSizePolicy | 動態調整堆中各區域大小以及晉升老年代的年齡 |
HandlePromotionFailure | 是否允許分配擔保失敗 |
ParallelGCThreads | 設置並行GC數 |
GCTimeRatio | 非GC時間與GC時間的比值,默認99,即允許1%的GC時間 |
MaxGCPauseMillis | GC最大停頓時間,僅僅適合Parallel Scavenge |
CMSInitiationgOccupancyFranction | CMS收集器在老年代空間被使用多少後觸發垃圾回收,默認68% |
UseCMSCompactAtFullCollection | CMS收集器在完成垃圾收集後是否要進行一次內存碎片整理 |
CMSFullGCsBeforeCompaction | CMS收集器在進行若干次垃圾回收再啓動一次內存碎片整理 |
監控工具
工具 | 作用 |
---|---|
jps | 虛擬機進程狀態工具 |
jstat | 虛擬機統計信息監控工具 |
jinfo | 虛擬機配置信息工具 |
jmap | 虛擬機內存映像工具 |
jhat | 虛擬機堆快照分析工具 |
jstack | 虛擬機堆棧跟蹤工具 |
JConsole | 虛擬機監視與管理控制工具 |
VisualVM | 多合一故障處理工具 |
命令行工具大都可以用 cmd -help 查看命令詳情。
jps是查看JVM進程狀態的工具,與linux的ps命令很像。格式爲 jps [options] [hostid] ,默認輸出虛擬機唯一ID(以下簡稱VMID,在沒有hostid參數時查看的是本地虛擬機,此時的VMID稱作LVMID,LVMID與操作系統進程ID一致,很多其他命令都需要該命令獲取的VMID作爲參數)和虛擬機執行主類名稱。-q 只顯示ID,不和其他任何參數一起用;-m 顯示傳遞給main函數的參數;-l 輸出主類全名(jar包顯示會輸出jar路徑);-v 輸出啓動時JVM配置參數(無虛擬機默認參數)。指定hostid後jps可以查看開啓了RMI的遠程虛擬機的進程狀態,hostid格式爲[protocol://][hostname][:port][/servername]。
jstat是監控虛擬機各種運行狀態的的命令行工具,它可以顯示本地或者遠程虛擬機的類裝載、內存、垃圾回收和JIT編譯等運行數據。格式爲jstat [option] vmid [ interval [s|ms] count],其中vmid可以是LVMID或hostid,interval [s|ms] count表示每個interval秒/毫秒(默認毫秒)查詢一次,共查詢count次,默認只查詢一次。-class監視類裝載、卸載數量、總空間以及類裝載耗時;-gc查看各區總容量與已使用容量,GC時間合計等;-gccapacity與-gc基本相同,但關注堆各個區域使用到的最大、最小空間;-gcutil與-gc基本相同,但關注個空間已使用部分的佔比;-gccause與-gcutil一樣,但會額外輸出上一次gc的原因;-gcnew關注-gc的新生代;-gcold關注-gc老年代;-gcnewcapacity關注-gccapacity的新生代;-gcoldcapacity關注-gccapacity的老年代;-gcpermcapacity關注-gccapacity的老年代;-compiler輸出JIT編譯過的方法、耗時等信息。
jinfo的作用是實時地查看和調整虛擬機各項參數。格式爲jinfo [options] vmid。選項**-flags顯示所有的虛擬機參數;-flag name** 顯示指定名字的虛擬機參數值;-flag [+|-] name使名字爲name的虛擬機參數起作用和不起作用;-sysprops查看系統屬性信息;不加參數時包括所有虛擬機參數和系統屬性信息。jps查看虛擬機的參數是顯示配置或顯示調用的,不包括虛擬機默認參數(默認參數需要查虛擬機手冊),而jinfo命令包含了虛擬機默認參數。
jmap用來查看內存信息或生成內存映像快照。格式爲jmap [options] vmid。選項 -dump:[live] format=b, file=<filename> 將堆快照保存到文件中,如果包含live只保存活着的對象; -heap顯示Java堆詳細信息,如哪種回收期、參數配置、分代狀況等;-histo[:live]顯示堆中對象統計信息,包括類、實例數量、合計容量,如果包含:live只統計活着的對象;-finalizerinfo顯示等待finalizer線程執行finalize方法的對象;-permstat以classloader爲統計口徑顯示永久代內存狀況。
jhat用來分析jmap生成的堆快照。格式爲jat <快照文件>。jhat內置一個微型http服務器,分析結果可以通過瀏覽器查看,我們一般不會在服務器上直接運行jhat來佔用服務器資源,而是將快照文件下載到本地用jhat分析。
jstack用於生成虛擬機當前時刻的線程快照。線程快照就是虛擬機內每一條線程正在執行的方法堆棧的集合,生成線程快照主要用來定位線程出現長時間停頓的原因,如線程死鎖、死循環、請求外部資源長時間等待等。格式爲jsack [option] vmid。-l 除堆棧外,顯示關於鎖的附加信息;-m如果調用native方法的話顯示C/C++堆棧。
JConsole是一款對JVM進行監視和管理的可視化工具。JSconsole工具在JDK/bin目錄下,啓動後將自動搜索本機運行的所有虛擬機,可以從中選擇一個虛擬機進行監控管理,也可以選擇界面的遠程進程來進行遠程服務器的監控。選擇虛擬機進入主界面後,共有6個標籤頁,分別爲概述、內存、線程、類、VM概要與MBean。概述主要包括堆內存使用情況、線程、類和CPU使用情況4種信息曲線圖,是後面幾個標籤頁的彙總。內存監控相當於可視化的jstat命令,用於監控虛擬機內存的變化趨勢。線程監控相當於可視化的jstat命令,遇到線程停頓時可以使用它進行監控分析,可以通過檢測死鎖來查看死鎖線程。
VisualVM是目前爲止隨JDK發佈的功能最強大的運行監視和故障處理程序,未來一段時間將會是官方主力發展的虛擬機故障處理工具。 VisualVM的性能分析功能比一些收費的商業工具(如JProfiler、YourKit)還要強大,而且它對應用程序的實際性能影響很小,可以直接應用在生產環境中,這也是很多商業工具無法與之媲美的。VisualVM工具對應JDK/bin目錄下的jvisualvm命令軟件。根據需要,VisualVM還可以安裝多個插件,在工具標籤下的插件選項中可查看可安裝與已安裝的插件。對於VisualVM不再做過多介紹,有了本章節知識,平時多使用自然能夠熟練掌握,平時還應該在別人的博客中瀏覽他們分享的,在實際項目中遇到的問題以及優化方法。
虛擬機執行子系統
類文件結構
JVM執行的是class類文件(無論是通過java編譯得到還是其他語言編譯得到,甚至是手動編寫的class文件),每一個class類文件都對應着一個類或接口的定義,然而本節所說的class文件並不一定是文件,可以是內存中的字節碼序列,或者來源於網絡的字節碼序列,只要滿足class文件格式。通過反編譯javap命令加-v或-verbose選項可以很友好地查看class文件。
在class文件中有兩種數據類型:無符號數和表。無符號數用u1、u2、u4、u8來代表1個字節、兩個字節、4個字節和8個字節的無符號數,可以用來描述數字、索引引用、數量值、utf8編碼字符串。表是由無符號數或者其他表組成的複合數據類型,習慣以"_info"結尾,整個class文件就是一個表。整個class文件結構如下:
類型 | 名稱 | 數量 | 說明 |
---|---|---|---|
u4 | magic | 1 | 很多文件都通過文件開頭的一個魔數來表示文件類型而非容易隨意更改的擴展名,不同的文件類型魔數不同,class文件的魔數爲0xCAFEBABE(咖啡寶貝) |
u2 | minor_version | 1 | 次版本號 |
u2 | major_version | 1 | 主版本號,Java 1.7.0對應的的主版本號爲50,次版本號爲0,十進制用50.0表示,每個版本的虛擬機可以執行該版本及低版本的字節碼(可能也能執行稍微高一點版本的) |
u2 | constant_pool_count | 1 | 常量池數量 + 1(1代表一個特殊用途空間),常量池從1開始計數,因爲第0個計數被用作特殊用途(某些指向常量池的索引不需要指向任何常量池時指向該空間) |
cp_info | constant_pool | constant_pool_count - 1 | 常量池(雖然絕大多數教程把這個結構叫着常量池,但從邏輯上講叫着常量項更合適),常量池中的常量分爲字面值(字符串常量、 |
u2 | access_flags | 1 | 訪問標識有兩個字節故可以表達16種標識,現只用8位,ACC_PUBLIC(0x0001)、ACC_FINAL(0x0010)、ACC_SUPER(0x0020)、ACC_INTERFACE(0x0200)、ACC_ABSTRACT(0x0400)、ACC_SYNTHETIC(0x1000標識此類並非由用戶代碼產生)、ACC_ANNOTATION(0x2000)、ACC_ENUM(0x4000) |
u2 | this_class | 1 | 當前類的類全名,指向常量池一個類型爲CONSTANT_Class_info的常量 |
u2 | super_class | 1 | 父類的類全名,指向常量池一個類型爲CONSTANT_Class_info的常量,沒有父類(只有Object類沒有父類)指向常量池特殊空間 |
u2 | interfaces_count | 1 | 實現或繼承(接口可以多繼承接口)的接口數量 |
u2 | interfaces | interfaces_count | 每一個實現或繼承的接口都指向常量池一個類型爲CONSTANT_Class_info的常量 |
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 | 類的額外屬性 |
成員變field_info量表表示一個字段,其結構如下:
類型 | 名稱 | 數量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 按位標識字段的修飾符,ACC_PUBLIC(0x0001)、ACC_PRIVATE(0x0002)、ACC_PROTECTED(0x0004)、ACC_STATIC(0x0008)、ACC_FINAL(0x0010)、ACC_VOLATILE(0x0040)、ACC_TRANSIENT(0x0080)、ACC_SYNTHETIC(0x1000標識此字段並非由用戶代碼產生的)、ACC_ENUM(0x4000) |
u2 | name_index | 1 | 字段名,指向常量區的CONSTANT_Utf8_info常量 |
u2 | descripter_index | 1 | 字段描述,指向常量區索引CONSTANT_Fieldref_info |
u2 | attributes_count | 1 | 成員變量的額外屬性數量 |
attribute_info | attruibutes | attruibutes_count | 成員變量的額外屬性 |
方法表method_info表示一個方法,其結構如下:
類型 | 名稱 | 數量 | 描述 |
---|---|---|---|
u2 | access_flags | 1 | 按位標識字段的修飾符,與字段相似 ,ACC_PUBLIC(0x0001)、ACC_PRIVATE(0x0002)、ACC_PROTECTED(0x0004)、ACC_STATIC(0x0008)、ACC_FINAL(0x0010)、ACC_SYNCHRONIZED(0x0020)、ACC_BRIDGE(0x0040編譯器產生的橋接方法)、ACC_VARARGS(0x0080方法是否接受不定參數)、ACC_NATIVE(0x0100)、ACC_ABSTRACT(0x0400)、ACC_STRICTFP(0x0800)、 ACC_SYNTHETIC(0x1000標識此方法並非由用戶代碼產生的) |
u2 | name_index | 1 | 方法名,指向常量區的CONSTANT_Utf8_info常量 |
u2 | descripter_index | 1 | 方法描述,指向常量區索引CONSTANT_Methodref_info |
u2 | attributes_count | 1 | 方法的額外屬性數量 |
attribute_info | attruibutes | attruibutes_count | 方法的額外屬性 |
<init>爲實例構造函數名,由編譯器生成,是構建實例時調用的函數(每一個程序定義的構造函數或默認的無參構造函數都會生成一個<init>函數),其中包括的代碼依次爲調用super構造函數、成員變量的直接初始化(如int a = 3)與成員塊的代碼(這兩者按在源碼中的先後順序排列)、自定義的構造函數代碼。<clinit>在類加載的初始化階段被調用,它也由編譯器生成,包括非fianl的static成員變量直接初始化(如staic int a = 3)與靜態塊的代碼(這兩者按在源碼中的先後順序排列)。class文件中一定&有lt;init>方法,不一定有<clinit>方法。
常量池的項目被分爲了14種類型,每一種類型都通過一個u1類型標識,不同的類型其具體結構不同,其中只有CONSTANT_Utf8_info類型的長度是不定的,具體各類型結構如下:
常量類型 | 項目 | 類型 | 描述 |
---|---|---|---|
CONSTANT_Utf8_info | tag | u1 | 標識常量類型,值爲1,標誌utf-8編碼的字節序列 |
length | u2 | 字符串以utf-8編碼後的字節長度 | |
bytes | u1 | 字符串以utf-8編碼後的字節序列 | |
CONSTANT_Integer_info | tag | u1 | 標識常量類型,值爲3,標識整型字面量 |
bytes | u4 | 高位在前存儲int值 | |
CONSTANT_Float_info | tag | u1 | 標識常量類型,值爲4,標識浮點型字面量 |
bytes | u4 | 高位在前存儲float值 | |
CONSTANT_Long_info | tag | u1 | 標識常量類型,值爲5,標識長整型字面量 |
bytes | u8 | 高位在前存儲long值 | |
CONSTANT_Double_info | tag | u1 | 標識常量類型,值爲6,標識雙精度實型字面量 |
bytes | u8 | 高位在前存儲double值 | |
CONSTANT_Class_info | tag | u1 | 標識常量類型,值爲7,標識類或接口的元信息 |
index | u2 | 指向全限定名CONSTANT_Utf8_info常量項的索引 | |
CONSTANT_String_info | tag | u1 | 標識常量類型,值爲8,標識字符串類型字面量 |
index | u2 | 指向字符串字面值CONSTANT_Utf8_info常量項的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 標識常量類型,值爲9,標識字段的元信息 |
index | u2 | 指向聲明該字段的類或接口描述符CONSTANT_Class_info的索引 | |
index | u2 | 指向字段描述符CONSTANT_NameAndType_info的索引 | |
CONSTANT_Methodref_info | tag | u1 | 標識常量類型,值爲10,標識類中方法的元信息 |
index | u2 | 指向聲明方法的類描述符CONSTANT_Class_info的索引 | |
index | u2 | 指向名稱及類型描述符CONSTANT_NameAndType_info的索引 | |
CONSTANT_InterfaceMethodref_info | tag | u1 | 標識常量類型,值爲11,標識接口中方法的元信息 |
index | u2 | 指向聲明方法的接口描述符CONSTANT_Class_info的索引 | |
index | u2 | 指向名稱及類型描述符CONSTANT_NameAndType_info的索引 | |
CONSTANT_NameAndType_info | tag | u1 | 標識常量類型,值爲12,標識字段或方法的名和類型 |
index | u2 | 指向該字段或方法名稱(實例構造函數名都爲<init>,類構造函數都爲<clinit>)常量項CONSTANT_Utf8_info | |
index | u2 |
指向該字段或方法類型常量項CONSTANT_Utf8_info 字段的類型有:B、C、D、F、I、J (long)、S、Z、V(void)、L(表示對象以;結尾,如Ljava/lang/String;)、[ (數組,如 [[I 表示int[][]) ) 方法類型表示方式:"(參數列表類型)返回值類型",如 (ILjava/lang/String;Ljava/lang/Integer;J)V 代表方法void funcName(int a, String b, Integer c, long d) |
|
CONSTANT_MethodHandle_info | tag | u1 | 標識常量類型,值爲15,標識方法句柄 |
reference_kind | u2 | ||
reference_index | u2 | ||
CONSTANT_MethodType_info | tag | u1 | 標識常量類型,值爲16,標識方法類型 |
descriptor_index | u2 | 指向CONSTANT_Utf8_info表示方法的描述符 | |
CONSTANT_InvokeDynamic_info | tag | u1 | 標識常量類型,值爲18,標誌一個動態方法調用點 |
bootstrap_method_attr_index | u2 | ||
name_and_type_index | u2 |
屬性表attribute_info表示一個額外用途的屬性,描述某些場景專有的信息,在整個類文件的表結構中、field_info和method_info中都包含了該表結構,其具體結構如下:
名稱 | 類型 | 數量 | 描述信息 |
---|---|---|---|
attribute_name_index | u2 | 1 | 屬性名,指向CONSTANT_Utf8_info |
attribute_length | u4 | 1 | 屬性值的長度 |
info | u1 | attribute_length | 屬性值 |
如果把嚴格的表結構比作bean對象,那麼包含屬性表結構的表就像是包含了一個Map成員的bean對象,可以向map中放入任何不重名的屬性並自己實現解析,attribute_info表結構讓包含它的表結構能夠更好地擴展,在Java7中有21項已經由虛擬機預定義的屬性,下面說明其中一部分:
預定義屬性名稱 | 可出現位置 | 概述 | 屬性結構(attribute_info的info部分) | 類型 | 細節描述 |
---|---|---|---|---|---|
Code | method_info | 存放方法的字節碼指令 | max_stack | u2 | 操作數棧大小 |
max_locals | u2 | 局部變量所需存儲空間 | |||
code_length | u4 | 指令長度 | |||
code | u1 | 現在所有指令都是一個字節長度 | |||
exception_table_length | u2 | ||||
exception_table | exception_info | 用excetion表而非跳轉指令處理異常 | |||
attibutes_count | u2 | ||||
attibutes | attibutes_info | ||||
Exceptions | method_info | 方法可拋出的異常(方法頭中的throws部分) | number_of_exceptions | u2 | |
exception_index_table | u2 | 指向異常類型CONSTANT_Class_info | |||
LineNumberTable | Code屬性表 | 保存源碼行號與字節碼行號關係(常用於異常打印源碼行號以及斷點調試) | line_number_table_length | u2 | |
line_number_table | line_number_info | 包含了兩個u2類型的數據項start_pc和line_number,分別對應字節碼行號和源碼行號 | |||
LocalVariableTable | Code屬性表 | 用於描述棧幀中局部變量表中的變量與Java源碼中定義的變量之間的關係 | local_variable_table_length | u2 | |
local_variable_table | local_variable_info | 該表結構包含了局部變量的作用域範圍、局部變量名稱以及在局部變量表中的位置 | |||
ConstantValue | field_info | 用於虛擬機自動爲靜態變量賦值(只有static final修飾的基本類型或String類型變量纔會生成該屬性) | constantvalue_index | u2 | 指向常量區基本類型或String類型的常量 |
其他屬性省略 |
異常表exception_info,當start_pc位置到end_pc位置(不包含end_pc)之間發生catch_type及其子類異常時,跳轉到handler_pc位置去執行指令,finally子句會生成catch塊個數 + 1個exception_info表(每一個catch的處理代碼發生異常會對應一個exception_info,try中發生未被catch的異常對應一個exception_info),其具體結構如下:
名稱 | 類型 | 數量 | 描述 |
---|---|---|---|
start_pc | u2 | 1 | 捕獲異常的開始位置 |
end_pc | u2 | 1 | 捕獲異常的結束位置 |
handler_pc | u2 | 1 | 異常發生時處理指令位置 |
catch_type | u2 | 1 | 可捕獲的異常類型,指向CONSTANT_Class_info常量 |
指令介紹
JVM指令由一個字節的操作碼和此操作碼所需的參數(操作數)組成,對於一個明確的操作碼,其對應操作數的字節長度是一定的。JVM採用面向操作數棧而非寄存器的架構,所以絕大多數指令都沒有操作數,只有操作碼。一條字節碼指令並非一條真正的機器指令,虛擬機也沒有保證一條字節碼指令的原子性。
對於大部分與數據類型相關的指令,他們的操作碼助記符中都有特殊字符來表明爲那種數據類型服務,如i代表int,l代表long,b代表byte,c代表char,f代表float,d代表double,a代表reference。對於某組指定(如Tipush,T代表數據類型),如果每一種數據類型都需要一個指令(如bipush、sipush、iipush …),那麼一個byte代表的256個指令可能會出現不夠用的情況,所以對於某組指令通常會提供有限個類型的指令(如Tipush實際上只支持bipush與sipush),而對於不支持類型的指令可以在必要的時候通過特殊的指令向支持的類型進行轉換。
對於帶有操作數的指令(如Tload,操作數爲變量位置),爲了避免減少指令長度,把操作碼和最常用的一些操作數列出來組成一個新的不含有操作數的指令(如iload 1是一個操作碼與一個操作數組成的指令,其效果和一個無操作數的指令iload_1相同),當然這樣的操作碼和操作數的組合有限,否則256個指令早就被用完了。
各指令對應的助記符如下表:
指令分類 | 操作碼 | 操作數 | 指令說明 |
---|---|---|---|
加載與儲存指令 | iload、lload、fload、dload、aload | 操作的局部變量位置 | 獲取一個局部變量並壓入操作棧 |
iload_<n> 、lload_<n> 、fload_<n> 、dload_<n>、 aload_<n> | 同Tload,只是操作碼自己包含了局部變量位置 | ||
istore、lstore、fstore、dstore、astore | 操作的局部變量位置 | 將棧頂元素彈出並保存到一個局部變量 | |
istore_<n> 、lstore_<n> 、fstore_<n> 、dstore_<n>、 astore_<n> | 同Tstore,只是操作碼自己包含了局部變量位置 | ||
bipush、sipush | 常量值 | 將一個常量壓入操作棧 | |
ldc、ldc_w、ldc2_w | 指向常量池的一個引用 | 從常量區取出常量壓入操作棧,ldc與ldc_w都是從常量區取一個字長,ldc2_w取兩個字長,ldc的操作數爲1個字節,故只能在常量區尋址1-255的常量,ldc_w和ldc2_w的操作數爲2字節 | |
aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f> 、dconst_<d> | 將一個常量壓入操作棧,操作碼自帶常量,iconst_m1壓入的-1,aconst_null壓入null | ||
運算指令 | 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 | 彈出棧頂兩個元素進行比較,並將比較結果入棧(比較結果可能是0、-1或1),dcmpg與dcmpl不同之處在於,當與NaN(未定義或不可表示的值)比較時,dcmpg值爲1,dcmpl值爲-1 | ||
類型轉換指令 | i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f | 彈出棧頂某個類型的元素強轉爲另一類型並壓入棧頂,用來進行顯式類型轉換操作或用來處理字節碼指令集中數據類型相關指令無法與數據類型一一對應的問題 | |
對象創建與訪問指令 | new | 對象類型,常量池CONSTANT_Class_info的引用 | 創建完成後,將對象的引用壓入棧 |
newarray | 單字節操作數atype(其值爲4-11的數,分別代表不同的基本類型) | 彈出棧頂元素作爲數組長度,創建一個基本類型的數組並將數組引用壓入棧 | |
anewarray | 指向常量池CONSTANT_Class_info引用 | 彈出棧頂元素作爲數組長度,創建一個引用類型(類型由操作數決定,如果創建的是N維數組,那麼其元素就是N-1維數組)的數組並將數組引用壓入棧 | |
multianewarray | 兩個操作數,第一個操作數爲指向常量池的數組類型CONSTANT_Class_info引用,第二個操作數爲可確定長度的維度 | 創建多維數組。第一個操作數爲數組類型而不像anewarray爲數組元素類型;第二個參數爲可確定長度的維度(在棧頂中有這麼多個數會彈出用於創建空間),而非數組維度,如new[2][3][4][][]的第二個操作數爲3,棧頂的三個元素分別爲2,3,4,這三個會依次彈出用於創建數組。最後將數組引用入棧 | |
getfield、putfield、getstatic、putstatic | 指向常量池CONSTANT_Fieldref_info的索引 | getfield彈出棧頂元素並獲取其成員變量(由操作數指定)壓入棧;putfield彈出棧頂兩個元素,並把棧頂第二個元素的成員變量(由操作數指定)設置爲棧頂元素;getstatic從操作數指定的靜態域獲取值並壓入棧;putstatic彈出棧頂元素並將操作數指定的靜態域設置爲該值 | |
baload、caload、saload、iaload、laload、faload、daload、aaload | 彈出棧頂兩個元素,將數組(由棧頂第二個元素指定)的第n個元素(由棧頂元素指定)入棧 | ||
bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore | 彈出棧頂三個元素,將數組(由棧頂第三個元素棧頂)的第n個元素(由棧頂第二個元素指定)的值設爲棧頂元素 | ||
arraylength | 彈出棧頂的數組引用,並將數組的長度壓入棧 | ||
instanceof、checkcase | 指向常量池CONSTANT_Class_info | instanceof彈出棧頂元素,檢查其是否屬於操作數指定的類型(結果爲1或0),將結果壓入棧;checkcase檢查棧頂元素(不會出棧)是否屬於操作數指定的類型(不屬於會拋出ClassCastException異常) | |
操作數棧管理指令 | pop、pop2 | pop彈出棧頂元素,pop2彈出棧頂兩個元素 | |
dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2 | dup複製棧頂元素插入棧頂;dup_x1複製棧頂第二個元素壓入棧頂;dup_x2複製棧頂第三個元素壓入棧頂;dup2複製棧頂兩個元素壓入棧頂;dup2_x1複製棧頂第二三個元素壓入棧;dup2_x2複製棧頂第三四個元素壓入棧 | ||
swap | 將棧頂兩個元素交換位置 | ||
控制轉移指令 | if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq、if_acmpne | 新指令位置 | 彈出棧頂兩個元素進行比較,如果滿足條件就跳轉到新指令位置繼續執行 |
ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull | 新指令位置 | 彈出棧頂一個元素與0進行比較,如果滿足條件就跳轉到新指令位置繼續執行 | |
tableswitch、lookupswitch | {匹配值1:新指令位置1, 匹配值2:新指令位置2,...[default:新指令位置n]} | 用於處理switch語句,當滿足某個匹配值時跳轉到相應的新指令位置。tableswitch彈出棧頂元素index並找到table的第index行的新指令位置(也就是說編譯期間就已經確定了位置,效率高),而lookupswitch彈出棧頂元素key並查找對應case爲key的行的新指令位置 | |
goto、goto_w | 指令位置 | 跳轉到操作數指定的指令位置,goto爲操作數爲兩字節,goto_w操作數爲4字節,兩者僅僅尋址能力有差別。另外有跳轉指令jsr、jsr_w、ret用於異常處理,但有了異常表後,這些指令已被廢棄 | |
方法調用指令 | invokevirtual | 指向常量池CONSTANT_Methodref_info | 詳見執行引擎部分方法調用 |
invokeinterface | 指向常量池CONSTANT_InterfaceMethodref_info | 詳見執行引擎部分方法調用 | |
invokespecial | 指向常量池CONSTANT_Methodref_info | 詳見執行引擎部分方法調用 | |
invokestatic | 指向常量池CONSTANT_Methodref_info | 詳見執行引擎部分方法調用 | |
invokedynamic | 指向常量池CONSTANT_InvokeDynamic_info | 詳見執行引擎部分方法調用 | |
ireturn、lreturn、freturn、dreturn、areturn、return | 方法返回 | ||
異常指令 | athrow | 將棧頂的異常對象彈出併到異常表匹配 | |
同步指令 | monitorenter、monitorexit | monitorenter彈出棧頂引用對象並進入其管程(synchronized開始),monitorexit彈出棧頂引用對象並退出期管程(synchronized結束),會生成一個異常表解決synchronized塊中異常需要調用monitorexit問題 |
類加載機制
虛擬機把描述類的數據從Class文件加載到內存, 並對數據進行校驗、轉換解析和初始
化, 最終形成可以被虛擬機直接使用的Java類型, 這就是虛擬機的類加載機制。
類加載過程
類從被加載到虛擬機內存中開始, 到卸載出內存爲止, 它的整個生命週期包括: 加載 、連接(細分爲驗證、準備 、解析三個階段) 、初始化、使用、卸載。從加載到初始化的過程爲類加載過程。
加載過程主要有三個階段,首先根據類全名找到定義該類的二進制字節流(字節流來源可以是網絡、class文件、jar包、代碼生成等,可以通過自定義加載器的loadClass方法來實現從不同源獲取字節流),然後將字節流代表的靜態數據結構轉換爲方法區的運行時數據結構,最後在內存中創建一個Class對象作爲訪問方法區中該類的各種數據結構的訪問入口(Class對象1.6在永久代中,1.7之後在堆中)。
驗證是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全,該階段可能拋出java.lang.VerifyError異常。主要驗證分爲四個階段:文件格式驗證(魔數、版本號、常量類型等,該次驗證發生在加載過程的讀取二進制流與存入方法區兩者之間,無法通過文件格式驗證是不會建立方法區運行時數據結構的,後面的驗證都是基於方法區運行時數據結構的)、元數據驗證(除了Object類必須有父類、父類是否是final、重載了final方法等,該階段保證不存在不符合Java語言規範的元數據信息)、字節碼驗證(跳轉指令在方法內跳轉、指令操作的棧元素的類型符合指令要求等,該階段通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件)、符號引用驗證(符號引用是否可被當前類訪問等,驗證發生在解析階段)。如果我們的字節碼以及第三方包是沒有問題的(我們通過編譯器編譯得到的字節碼通常是沒有問題的),爲了提高類加載效率,可以通過-Xverify: none禁止掉驗證過程。
準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這裏分配的是類變量而非實例變量的內存,即static變量。這裏的初始化先初始爲0值(對象0值爲null,數字型0值爲0,boolean的0值爲false),如果該字段有ConstantValue屬性,那麼用該屬性值初始化。
解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,虛擬機實現可以對第一次解析的結果進行緩存,JVM規範並不強制要求該過程發生在類加載過程,可以在運行到相應代碼需要解析時再解析。
初始化的過程是執行類構造器<clinit>的過程,<clinit>方法由編譯器自動收集非final修飾的static變量以及靜態語句塊生成。<clinit>是編譯過程中生成的, 其中代碼包括static變量的初始化(static變量的初始化有兩種方式,非final字段放入clinit函數中在初始化階段進行初始化,final字段使用字段的ConstantValue屬性在準備階段進行初始化)與靜態塊的初始化,這兩者按在源碼中的先後順序排列,<clinit>不需要顯式調用父類的<clinit>方法,虛擬機會確保一個類調用<clinit>方法的時候其父類的<clinit>方法已經調用完成,同一個類加載器加載的類,其<clinit>方法只會執行一次。如果一個類沒有初始化過,有且僅有5種情況會觸發對類的初始化:
- 遇到new、getstatic、putstatic、invokestatic時。如果是SonClass.SuperStaticField或者SonClass.SuperStaticMethod(),那麼只會初始化父類(除非配置了-XX: +TraceClassLoading纔會同時初始化子類)。
- 使用java.lang.reflect包的方法對類進行反射調用的時候。
- 初始化子類前必須保證其父類已經初始化。
- 虛擬機啓動時,要執行的主類會被初始化。
- java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄。
類加載器
Java源代碼通過編譯,得到虛擬機能夠識別的字節碼文件,在虛擬機運行過程中需要用到某個類時,通過類加載器分析該類及其依賴類的字節碼文件,並在系統中生成Class類的對象。每個java程序至少會有三個類加載器,引導類加載器BootstrapClassLoader、擴展類加載器ExtensionClassLoader和應用類加載器AppClassLoader(也叫系統類加載器),這三個名字並非其真實類名,而且開發人員可以自己定製類加載器,除了引導類加載器,所有的加載器都是ClassLoader的子類。
每個加載器都對應着一個命名空間,同一個加載器的命名空間中不能出現相同的類全名(由於雙親委派機制,有直系血緣關係的任意兩個加載器中不可能出現同名的類,除非打破雙親委派機制),但不同空間可以。對於虛擬機,要判斷兩個類是否相同,不僅看類全名,而且看該類的加載器是否相同,也就是命名空間是否相同,其實就是看該Class對象內存地址是否相同,就算是同一個class文件,也可能被不同的加載器加載到不同的地址,當然同一個加載器加載類全名相同的類兩次,實際上第二次只會尋找到就返回。在某個類中,只有加載該類的加載器及其父加載器加載的類纔是可見的(可以被引用),而包可見屬性指的是運行時包,也就是同一個包且由用一個加載器加載(就算是被父加載器加載的也不行)的類才能訪問包可見代碼。
類加載器也是Java類,因此類加載器本身也是要被類加載器加載的,顯然必須有第一個類加載器不需要被加載,這個加載器就是BootstrapClassLoader,它不是java類,是JVM啓動核心包含的一段代碼。因爲引導類加載器不是一個java類,所以很多平臺的JVM在其他地方獲得加載器的時候如果得到的是這個引導類加載器,那麼實際得到的將會是個null,如擴展類加載器的父類是引導類加載器,那麼擴展類加載器的getParent()就返回null。
類加載的過程實際上是尋找到字節碼加載到內存中生成Class類對象的過程,一般尋找字節碼的方式有直接根據包名對應的路徑在本地文件系統尋找、從網絡流中尋找、從jar等壓縮包尋找和動態將源文件編譯成class文件得到字節碼等,開發者可以自己實現類加載器來定義尋找方式,甚至在得到序列後可以進行特定的解密和驗證來得到真正的字節碼,之後生成Class對象的過程開發人員一般不自己改動。
一個加載器的加載器(Object的getClassLoader()方法獲得)和父加載器(ClassLoader的getClassLoader()方法獲得)是沒直接關係的,類加載器是Class類的屬性,它表示的是加載該類的ClassLoader對象,因爲加載器本身也是一個類,所以它被加載到內存形成一個Class對象的時候也有一個加載器對象,而父加載器只有ClassLoader實例纔有,他是ClassLoader實例的屬性,默認是應用加載器,但創建該加載器時是可以指定的,它是爲了便於管理加載器以及實現加載器的雙親委託機制。加載器的加載器和父加載器都是一個加載器的實例對象,而被加載的是類對象,子加載器卻是一個加載器的對象。從ClassLoader的無參構造函數可以看到,默認的父加載器是系統加載器。一個加載器的加載器和其父加載器並不一定是同一個加載器,如擴展加載器和應用加載器都是由引導類加載器加載的,但擴展加載器的父加載器是引導類加載器,而應用加載器的父加載器卻是擴展類加載器。
加載器既然具有父子結構,那麼就構成了一顆樹,根是引導類加載器,從根開始依次爲引導類加載器->擴展類加載器->系統加載器->各類自定義加載器,當然這些都是默認的關係,我們可以在調用構造函數時直接指定父加載器,從而將其掛在樹的相應位置。
加載器的loadClass方法中就實現了雙親委派機制,用某個加載器去加載類,首先會使用其父加載器去加載,只有在父加載器加載失敗的時候纔會調用自身去加載,如果還是失敗就拋出找不到類異常,這樣一定會首選使用引導類加載器去加載類。委託機制的好處是提高軟件系統的安全性,因爲如果用戶自定義的類與由父加載器可加載的可靠類全名相同,加載器不會加載到自定義的類,從而防止不可靠甚至惡意的代碼代替由父加載器加載的可靠代碼,而且父加載器加載的包可見代碼,子加載器加載的代碼就算僞造爲相同的包也無法調用。loadClass函數實現了委派機制(該方法爲典型的模板方法模式),一般不要去改動。
各個加載器的找尋字節碼的方式如下,啓動類加載器加載只會尋找jre/lib/rt.jar包裏的所有class文件,擴展類加載器尋找jre/lib/ext/(System.getProperty(“java.ext.dirs”)得到的目錄)目錄下的所有jar包中的class文件,系統類加載器只會在classpath目錄下及其目錄下的jar包中尋找class文件。虛擬機並不是用java寫的(也不可能用java寫,否則java程序和虛擬機形成相互依賴的死鎖,實際是C++寫的),在虛擬機啓動時(標準的java啓動過程,有些框架可以自己修改虛擬機啓動過程),執行其他語言編寫的程序,搭建java運行環境,這會將$JAVA_HOME/jre/rt.jar下的所有類加載到系統中,構造出java運行最基本的類對象,這個構造java運行最基本的類對象的過程就是引導類加載器加載類的過程,可見引導類的概念是虛擬的,並非一個實際的類,他加載類的過程實際上是一段其他語言實現的代碼構造類對象的過程,所以引導類加載器不存在父加載器,而其自身也無對應的Class對象,所以它直接加載的類通過getClassLoader方法將返回null,而且其子加載器調用getParent也只能返回null;引導加載器加載的過程中加載了一個擴展類加載器,該加載器在引導類加載器加載完後會負責加載$JAVA_HOME/jre/lib/同-Djava.ext.dirs指定目錄下的jar包裏的類;擴展類加載器加載的過程中加載了一個應用類加載器,該加載器負責在擴展類加載器加載完後加載classpath指定的目錄中的class文件和jar文件中的類。
當某個類需要加載時,可以指定某個類加載器去加載該類,默認使用當前類加載器(引發此次加載的類被加載時使用的類加載器)。
破壞雙親委派機制-線程上下文加載器,如果某個類被某個加載器加載,而在該類中需要加載的另一個類無法通過該加載器進行加載,就需要顯示指定一個類加載器進行加載,這在SPI框架(如JNDI、JDBC等)中比較常見。Java中很多SPI框架都存放在rt.jar包中,而服務方對SPI的實現作爲第三方包大多放在classpath下,由於雙親委派機制的存在,SPI框架的代碼都由啓動類加載器加載,當SPI框架需要加載第三方包時,只能指定一個類加載器對第三方包進行加載,由於SPI框架是通用框架,無法預料第三方實現以何種方式提供(可能是jar包、也可能是網絡直接序列),所以不應該硬性指定類加載器,可以通過參數的形式傳入,但在java中一般通過當前線程上下文獲取類加載器。通過線程的getContextClassLoader與setContextClassLoader進行線程上下文加載器的獲取和設置,在新建線程時默認與父線程的類加載器相同,最初的線程默認爲系統類加載器。可見,線程上下文加載器並沒有破壞雙親委派機制,而是對雙親委派機制的補充,它和雙親委派機制根本就是毫無關係的兩個維度。只有重寫了ClassLoader的loadClass方法纔可能破壞雙親委派機制。
類加載器還可以用來加載器其他資源,如圖片,配置文件。
// 除了引導類加載器,所有加載器的父類。
public abstract class ClassLoader {
protected ClassLoader(ClassLoader parent); // 指定父加載器。
protected ClassLoader(); // 以getSystemClassLoader()返回的加載器作爲父加載器。
public final ClassLoader getParent();
public static ClassLoader getSystemClassLoader(); // 獲取應用加載器
public Class loadClass(String name) // loadClass(name, false);
protected Class loadClass(String name, boolean resolve) { // 加載類,實現了雙親委派機制,resolve加載過程中是否連接。
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name); // 查看類是否已經被當前加載器加載過
if (c == null) {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
if (c == null) {
c = findClass(name); // 由本加載器進行加載
}
}
// 加載過程中是否連接
if (resolve) {
resolveClass(c);
}
}
return c;
}
protected final Class findLoadedClass(String name); // 查看當前加載器是否加載過指定類
protected Class findClass(String name); // 該方法負責真正地加載類,自定義ClassLoader一般只需要實現該方法,該方法首先獲字節碼序列,然後直接調用defineClass方法生成Class對象
protected final Class defineClass(String name, byte[] b, int off, int len); // 將字節碼序列轉換爲Class對象,類加載過程主要是通過該方法完成
protected final Class defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain);
protected final Class defineClass(String name, java.nio.ByteBuffer b, ProtectionDomain protectionDomain);
protected final Class findSystemClass(String name);
protected final void resolveClass(Class c); // 連接
protected URL findResource(String name); // 獲取名爲name的資源,由具體加載器實現
public URL getResource(String name); // 用findResource尋找資源,但實現了雙親委派機制
public InputStream getResourceAsStream(String name);
public Enumeration<URL> getResources(String name); // 獲取當前加載器及其所有祖先加載器可獲取的所有資源
public static URL getSystemResource(String name); // getSystemClassLoader().getResource(name)
public static InputStream getSystemResourceAsStream(String name); // getSystemClassLoader().getSystemResourceAsStream(name)
public static Enumeration<URL> getSystemResources(String name); // getSystemClassLoader().getSystemResources(name)
}
Java agent
在類加載過程中,如果需要對字節碼進行處理,那麼可以自定義ClassLoader來完成這項工作,但這樣做存在兩點問題,首先,自定義的ClassLoader作爲框架層,很可能被業務層的需求所污染,其次,由於雙親委派機制的存在,無法處理父ClassLoader加載的類。JVM在類加載過程中提供了擴展接口在類加載過程中進行攔截,業務可以自定義ClassFileTransformer來完成攔截功能。JVM中應該存在一個單例的Instrumentation實例,該實例可用於ClassFileTransformer的註冊,可以通過Java agent方式來獲取系統中的Instrumentation實例。
public interface Instrumentation {
//註冊一個Transformer,從此之後的類加載都會被Transformer攔截,Transformer可以直接對類的字節碼byte[]進行修改
void addTransformer(ClassFileTransformer transformer);
//對JVM已經加載的類重新觸發類加載,使用的就是上面註冊的Transformer。
// retransformation可以修改方法體,但是不能變更方法簽名、增加和刪除方法/成員屬性
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
// 獲取一個對象的大小
long getObjectSize(Object objectToSize);
// 將一個jar加入到bootstrap classloader的classpath裏
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
// 獲取當前被JVM加載的所有類對象
Class[] getAllLoadedClasses();
}
// 字節碼攔截處理
public interface ClassFileTransformer {
byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer);
}
Java agent有兩種實現方式,一種方式是JVM啓動時指定啓動參數,另一種方式是通過Attach API,第二種方式能在不重啓虛擬機的條件下完成。無論是哪種方式,首先都需要製作一個jar包,並在其manifest文件中指定入口類,如MyAgent。
// jar包中Java agent的入口類
public class MyAgent {
// 以vm參數的形式載入時需要實現該方法,同時需要在jar包的manifest中配置條目Premain-Class:package.MyAgent
public static void premain(String agentArgs, Instrumentation inst);
// 以Attach的方式載入時需要實現該方法,同時需要在jar包的manifest中配置條目Agent-Class:package.MyAgent
public static void agentmain(String agentArgs, Instrumentation inst);
}
以JVM參數形式實現Java agent,只需要在java啓動命令中加入-javaagent:**.jar參數即可,這樣就會在執行main函數之前執行該jar包的manifest文件中Premain-Clas條目指定類的靜態方法premain(…)。
以Attach API方式實現Java agent,可以在額外的進程中完成,當目標JVM啓動後(目標JVM是一個普通的JVM,沒有任何額外的代碼),由額外的進程發送信號給目標JVM,由目標JVM來執行MyAgent的agentmain(…)方法。額外進程一般是另一個java程序,其代碼示例如下。
// VirtualMachine是與目標JVM溝通的橋樑,其定義在JDK的lib/tools.jar中,JRE中無此包,所以需要額外引入該jar包纔可進行編碼
// VirtualMachine.attach的參數是目標JVM的進程pid,可以通過返回的VirtualMachine與目標JVM進行通信
VirtualMachine vm = VirtualMachine.attach("1234");
try {
// 指定agent的jar包路徑,發送給目標JVM,由目標JVM執行該jar包的manifest文件中Agent-Class條目指定類的靜態方法agentmain(...)
vm.loadAgent(".../agent.jar");
} finally {
// 斷開與目標JVM的通道
vm.detach();
}
執行引擎
JVM的執行引擎在執行class字節碼時有解釋執行與編譯執行兩種方式,Hotspot虛擬機使用兩者並存的方式,編譯執行熱點代碼,解釋執行其他代碼,這裏主要討論解釋執行。
JVM的執行引擎是基於棧結構而非寄存器,也就是說JVM指令依賴於棧中的數據而非寄存器中的數據。基於棧解釋執行的具體過程可以參看上文運行時內存結構-虛擬機棧-操作數棧處,下面會具體討論方法調用過程。
變量的靜態類型與動態類型。如果有Parent var = new Son(),那麼變量var的靜態類型就是其引用類型Parent,在整個生命週期中,變量的靜態類型是不會改變的,變量var的動態類型就是其實例對象的類型Son,變量的動態類型有可能發生變化,如將Parent的任一子類實例賦值給var,那麼var的動態類型就變成了該子類類型。Java編譯器只知道變量的靜態類型,而變量的動態類型只有在運行時才知道。
JVM通過方法調用指令進行方法調用,方法調用指令包括操作碼(JVM提供了5種與方法調用相關的操作碼,下面的)和操作數(方法調用指令的操作數都爲指向CONSTANT_Methodref_info或CONSTANT_InterfaceMethodref_info的引用,它包括方法所屬類、方法簽名(方法名+方法類型), invokedynamic除外)。Java源文件在編譯成class文件時必須確定指令即操作碼與操作數,操作碼稍後介紹,這裏看看如何確定操作數。如Parent parent = new Son(); parent.fun(“字符串參數”);由於在編譯期間只能確定靜態類型,所以操作數的類爲Parent;如果Parent或其父類中有方法fun(String arg)那麼操作數的方法簽名就是它,否則找到最匹配方法的簽名,也就是從多個同名的重載方法中(可能只有一個方法)找到最匹配的方法,如果找不到匹配的就編譯異常。最匹配的優先級解釋如下,對於引用類型,與自己血緣關係最近的祖先進程越能匹配;對於基本類型有如下順序,char / (byte > short) > int > long > float > double > 對應封裝類型 > 對應封裝類型的祖先類型,處於前面的類型可以匹配處於後面的類型,但處於前面的類型無法匹配處於後面類型的封裝類型(如byte無法匹配Integer);只有在前面兩者都無法滿足的情況下才會考慮可變參數方法的匹配。注意,操作數指定的類的某個方法並不帶表它是運行時真正被調用的方法,運行時真正被調用的方法的方法簽名一定與操作碼的方法簽名相同,但運行時被真正被調用方法所屬的類卻不一定是操作碼的類。
方法調用指令的核心在於如何根據方法調用指令的操作數找到被調用方法的入口地址(就是從符號引用找到直接引用的過程)。JVM針對不同的調用場景提供了5個方法調用相關的操作碼。
操作碼 | 操作數類型 | 棧數據 | 說明 | java源碼編譯 |
---|---|---|---|---|
invokestatic | CONSTANT_Methodref_info | [arg1, arg2, …] → result | 從操作數(類 + 方法簽名)便能找到方法的入口地址 | 調用static方法 |
invokespecial | CONSTANT_Methodref_info | objectref, [arg1, arg2, …] → result | 從操作數(類 + 方法簽名)便能找到方法的入口地址 | 調用實例構造器<init>方法、私有方法和父類方法 |
invokeinterface | CONSTANT_InterfaceMethodref_info | objectref, [arg1, arg2, …] → result | 需要根據動態類型 + 操作數(方法簽名)找到方法的入口地址 | 通過接口引用調用方法 |
invokevirtual | CONSTANT_Methodref_info | objectref, [arg1, arg2, …] → result | 需要根據動態類型 + 操作數(方法簽名)找到方法的入口地址 | 通過非接口引用調用非static且非special方法 |
invokedynamic | CONSTANT_InvokeDynamic_info | [arg1, arg2 …] → result | Java7開始加入的新操作碼 | 通過java代碼編譯無法得到該指令 |
從上表可以看出,所有的方法調用指令(invokedynamic除外)最終都是根據類全名+方法簽名來尋找到直接地址,方法簽名都是來源於操作碼,類全名可能來源於操作碼,也可能來源於操作數棧。對於invokespecial,其類全名下對應的方法簽名一定是存在的;對於invokestatic、invokeinterface、invokevirtual,其類全名下對應的方法簽名不一定存在,如果不存在就找其父類下對應的方法簽名,一直到找到爲止。
在實現上,根據類全名+方法簽名尋找直接引用比較耗時,一般在方法區爲每一個類維護一張虛方法表vtable(接口維護一張接口方法表itable),在類加載的解析階段將類中各個方法的符號引用與直接引用的對應關係寫入表中(如果有未被覆蓋的祖先類方法,也寫入該表中)。
高效併發
見多線程一章