Java的生命旅程

開頭小貼士:不做博客的搬運工,答應我,好嗎?

由於篇幅流程很長, 整個流程也很複雜, 請耐心閱讀。

總的執行流程圖 (實際執行並非這樣,畫這個流程圖只是爲了加深理解)

image.png

實例代碼

主程序

package debug.jvm;

public class JvmTest {
    public static void main(String[] args) {
        Parent parent = new Parent();
        Parent son1 = new Son1();
        Parent son2 = new Son2();

        parent.say(50);
        son1.say(20);
        son2.say(30);
    }
}

父類

class Parent{
    public static int s_age;
    public int c_age;
    static{

    }
    public void say(int age){
        System.out.println("parent: "+age);
    }
}

子類1

class Son1 extends Parent{

    @Override
    public void say(int age) {
        System.out.println("son1 say "+age);
    }
}

子類2

class Son2 extends Parent{
    @Override
    public void say(int age) {
        System.out.println("son2 say "+age);
    }
}

我們以這個代碼爲例 看一下整個過程是如何執行的

編譯期

前端編譯器

任務

將符合Java語言規範的源碼編譯生成符合Java虛擬機規範的Class文件

主要工具

使用Java源碼級編譯工具Javac 就是JDK自身攜帶的命令,但是你知道嗎,Javac編譯器不像HotSpot虛擬機那樣使用C++語言(包含少量C語言)實現,它本身就是一個由Java語言編寫的程序,這爲純Java的程序員瞭解它的編譯過程帶來了很大的便利,具體可以查看相應的源代碼(源碼部分)。想了解編譯的更多詳情,請查閱 《編譯原理》

主要過程

image.png

1.詞法分析器

將源代碼的字符流轉變爲標記(Token)集合的過程,單個字符是程序編寫時的最小元素,但標記纔是編譯時的最小元素。關鍵字、變量名、字面量、運算符都可以作爲標記,如“int a=b+2”這句代碼中就包含了6個標記,分別是int、a、=、b、+、2,雖然關鍵字int由3個字符構成,但是它只是一個獨立的標記,不可以再拆分。在Javac的源碼中,詞法分析過程由com.sun.tools.javac.parser.Scanner類來實現。最終生成Token序列進行下一步的處理
image.png

2.語法分析器

是根據Token序列構造抽象語法樹的過程,抽象語法樹(Abstract Syntax Tree,AST)是一種用來描述程序代碼語法結構的樹形表示方式,抽象語法樹的每一個節點都代表着程序代碼中的一個語法結構,例如包、類型、修飾符、運算符、接口、返回值甚至連代碼註釋等都可以是一種特定的語法結構。

3.插入式註解處理器的註解處理

插入式註解處理器的執行階段,插入式註解處理器”的標準API,可以提前至編譯期對代碼中的特定註解進行處理,從而影響到前端編譯器的工作過程。我們可以把插入式註解處理器看作是一組編譯器的插件,當這些插件工作時,允許讀取、修改、添加抽象語法樹中的任意元素 譬如Java著名的編碼效率工具Lombok 

4.語義分析器

經過語法分析之後,編譯器獲得了程序代碼的抽象語法樹表示,抽象語法樹能夠表示一個結構正確的源程序,但無法保證源程序的語義是符合邏輯的。而語義分析的主要任務則是對結構上正確的源程序進行上下文相關性質的檢查,譬如進行類型檢查、控制流檢查、數據流檢查,等等
我們編碼時經常能在IDE中看到由紅線標註的錯誤提示,其中絕大部分都是來源於語義分析階段的檢查結果。

  • 標註檢查。對語法的靜態信息進行檢查。

標註檢查步驟要檢查的內容包括諸如變量使用前是否已被聲明、變量與賦值之間的數據類型是否能夠匹配,在標註檢查中,還會順便進行一個稱爲常量摺疊的代碼優化

  • 數據流及控制流分析。對程序動態運行過程進行檢查。

數據流分析和控制流分析是對程序上下文邏輯更進一步的驗證,它可以檢查出諸如程序局部變量在使用前是否有賦值、方法的每條路徑是否都有返回值、是否所有的受查異常都被正確處理了等問題。編譯時期的數據及控制流分析與類加載時的數據及控制流分析的目的基本上可以看作是一致的,但校驗範圍會有所區別,有一些校驗項只有在編譯期或運行期才能進行

  • 解語法糖。將簡化代碼編寫的語法糖還原爲原有的形式。

語法糖(Syntactic Sugar),也稱糖衣語法,是由英國計算機科學家Peter J.Landin發明的一種編程術語,指的是在計算機語言中添加的某種語法,這種語法對語言的編譯結果和功能並沒有實際影響,但是卻能更方便程序員使用該語言。通常來說使用語法糖能夠減少代碼量、增加程序的可讀性,從而減少程序代碼出錯的機會
image.png
常見語法糖-範型、Lamda表達式

5.字節碼生成

字節碼生成是Javac編譯過程的最後一個階段,在Javac源碼裏面由com.sun.tools.javac.jvm.Gen類來完成。字節碼生成階段不僅僅是把前面各個步驟所生成的信息(語法樹、符號表)轉化成字節碼指令寫到磁盤中,編譯器還進行了少量的代碼添加和轉換工作。完成了對語法樹的遍歷和調整之後,就會把填充了所有所需信息的符號表交到com.sun.tools.javac.jvm.ClassWriter類手上,由這個類的writeClass()方法輸出字節碼,生成最終的Class文件,到此,整個編譯過程宣告結束。

後端編譯器

即時編譯器都是特指HotSpot虛擬機內置的即時編譯器,虛擬機也是特指HotSpot虛擬機

Java程序最初都是通過解釋器(Interpreter)進行解釋執行的,當虛擬機發現某個方法或代碼塊的運行特別頻繁,就會把這些代碼認定爲“熱點代碼”(Hot Spot Code),爲了提高熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成本地機器碼,並以各種手段儘可能地進行代碼優化,運行時完成這個任務的後端編譯器被稱爲即時編譯器。本節我們將會了解HotSpot虛擬機內的即時編譯器的運作過程,此外,我們還將解決以下幾個問題

內部都同時包含解釋器與編譯器,解釋器與編譯器兩者各有優勢:當程序需要迅速啓動和執行的時候,解釋器可以首先發揮作用,省去編譯的時間,立即運行。當程序啓動後,隨着時間的推移,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼,這樣可以減少解釋器的中間損耗,獲得更高的執行效率。當程序運行環境中內存資源限制較大,可以使用解釋執行節約內存(如部分嵌入式系統中和大部分的JavaCard應用中就只有解釋器的存在),反之可以使用編譯執行來提升效率。同時,解釋器還可以作爲編譯器激進優化時後備的“逃生門”(如果情況允許,HotSpot虛擬機中也會採用不進行激進優化的客戶端編譯器充當“逃生門”的角色),讓編譯器根據概率選擇一些不能保證所有情況都正確,但大多數時候都能提升運行速度的優化手段,當激進優化的假設不成立,如加載了新類以後,類型繼承結構出現變化、出現“罕見陷阱”(Uncommon Trap)時可以通過逆優化(Deoptimization)退回到解釋狀態繼續執行

HotSpot虛擬機中內置了兩個(或三個)即時編譯器,其中有兩個編譯器存在已久,分別被稱爲“客戶端編譯器”(Client Compiler)和“服務端編譯器”(Server Compiler),或者簡稱爲C1編譯器和C2編譯器

由於即時編譯器編譯本地代碼需要佔用程序運行時間,通常要編譯出優化程度越高的代碼,所花費的時間便會越長;而且想要編譯出優化程度更高的代碼,解釋器可能還要替編譯器收集性能監控信息,這對解釋執行階段的速度也有所影響。爲了在程序啓動響應速度與運行效率之間達到最佳平衡,HotSpot虛擬機在編譯子系統中加入了分層編譯的功能

分層編譯

實施分層編譯後,解釋器、客戶端編譯器和服務端編譯器就會同時工作,熱點代碼都可能會被多次編譯,用客戶端編譯器獲取更高的編譯速度,用服務端編譯器來獲取更好的編譯質量,在解釋執行的時候也無須額外承擔收集性能監控信息的任務,而在服務端編譯器採用高複雜度的優化算法時,客戶端編譯器可先採用簡單優化來爲它爭取更多的編譯時間

熱點代碼

熱點代碼主要有兩類,包括:

  • 被多次調用的方法。
  • 被多次執行的循環體。


對於這兩種情況,編譯的目標對象都是整個方法體,而不會是單獨的循環體,而對於後一種情況,儘管編譯動作是由循環體所觸發的,熱點只是方法的一部分,但編譯器依然必須以整個方法作爲編譯對象,只是執行入口(從方法第幾條字節碼指令開始執行)會稍有不同,編譯時會傳入執行入口點字節碼序號(Byte Code Index,BCI)。這種編譯方式因爲編譯發生在方法執行的過程中,因此被很形象地稱爲“棧上替換”(On Stack Replacement,OSR),即方法的棧幀還在棧上,方法就被替換了

觸發的條件

而在HotSpot虛擬機中使用的是第二種基於計數器的熱點探測方法,爲了實現熱點計數,HotSpot爲每個方法準備了兩類計數器:方法調用計數器(Invocation Counter)和回邊計數器(Back Edge Counter,“回邊”的意思就是指在循環邊界往回跳轉)。當虛擬機運行參數確定的前提下,這兩個計數器都有一個明確的閾值,計數器閾值一旦溢出,就會觸發即時編譯。


image.png

優化技術

最重要的優化技術之一:方法內聯
首先,第一個要進行的優化是方法內聯,它的主要目的有兩個:一是去除方法調用的成本(如查找方法版本、建立棧幀等);二是爲其他優化建立良好的基礎。方法內聯膨脹之後可以便於在更大範圍上進行後續的優化手段,可以獲取更好的優化效果


最前沿的優化技術之一:逃逸分析
逃逸分析的基本原理是:分析對象動態作用域,當一個對象在方法裏面被定義後,它可能被外部方法所引用,例如作爲調用參數傳遞到其他方法中,這種稱爲方法逃逸;甚至還有可能被外部線程訪問到,譬如賦值給可以在其他線程中訪問的實例變量,這種稱爲線程逃逸;從不逃逸、方法逃逸到線程逃逸,稱爲對象由低到高的不同逃逸程度

  • 棧上分配 (Stack Allocations):在Java虛擬機中,Java堆上分配創建對象的內存空間幾乎是Java程序員都知道的常識,Java堆中的對象對於各個線程都是共享和可見的,只要持有這個對象的引用,就可以訪問到堆中存儲的對象數據。虛擬機的垃圾收集子系統會回收堆中不再使用的對象,但回收動作無論是標記篩選出可回收對象,還是回收和整理內存,都需要耗費大量資源。如果確定一個對象不會逃逸出線程之外,那讓這個對象在棧上分配內存將會是一個很不錯的主意,對象所佔用的內存空間就可以隨棧幀出棧而銷燬。在一般應用中,完全不會逃逸的局部對象和不會逃逸出線程的對象所佔的比例是很大的,如果能使用棧上分配,那大量的對象就會隨着方法的結束而自動銷燬了,垃圾收集子系統的壓力將會下降很多。棧上分配可以支持方法逃逸,但不能支持線程逃逸。

  • 標量替換(Scalar Replacement):若一個數據已經無法再分解成更小的數據來表示了,Java虛擬機中的原始數據類型(int、long等數值類型及reference類型等)都不能再進一步分解了,那麼這些數據就可以被稱爲標量。相對的,如果一個數據可以繼續分解,那它就被稱爲聚合量(Aggregate),Java中的對象就是典型的聚合量。如果把一個Java對象拆散,根據程序訪問的情況,將其用到的成員變量恢復爲原始類型來訪問,這個過程就稱爲標量替換。假如逃逸分析能夠證明一個對象不會被方法外部訪問,並且這個對象可以被拆散,那麼程序真正執行的時候將可能不去創建這個對象,而改爲直接創建它的若干個被這個方法使用的成員變量來代替。將對象拆分後,除了可以讓對象的成員變量在棧上(棧上存儲的數據,很大機會被虛擬機分配至物理機器的高速寄存器中存儲)分配和讀寫之外,還可以爲後續進一步的優化手段創建條件。標量替換可以視作棧上分配的一種特例,實現更簡單(不用考慮整個對象完整結構的分配),但對逃逸程度的要求更高,它不允許對象逃逸出方法範圍內。

語言無關的經典優化技術之一:公共子表達式消除
語言相關的經典優化技術之一:數組邊界檢查消除

Class文件結構

編譯過程結束後,萬里長征才走完第一步,已經生成了Class文件,下面我們來看看Class文件佈局
爲了更加深刻的理解下面的內容,我們需要看一下官方的定義

image.png
JVM官方規範-Class文件結構

根據《Java虛擬機規範》的規定,Class文件格式採用一種類似於C語言結構體的僞結構來存儲數據,這種僞結構中只有兩種數據類型:“無符號數”和“表”。後面的解析都要以這兩種數據類型爲基礎,所以這裏筆者必須先解釋清楚這兩個概念。

  • 無符號數屬於基本的數據類型,以u1、u2、u4、u8來分別代表1個字節、2個字節、4個字節和8個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。
  • 表是由多個無符號數或者其他表作爲數據項構成的複合數據類型,爲了便於區分,所有表的命名都習慣性地以“_info”結尾。表用於描述有層次關係的複合結構的數據,整個Class文件本質上也可以視作是一張表,這張表

魔數 (4個字節)

每個Class文件的頭4個字節被稱爲魔數(Magic Number),它的唯一作用是確定這個文件是否爲一個能被虛擬機接受的Class文件,Class文件的魔數取得很有“浪漫氣息”,值爲0xCAFEBABE(咖啡寶貝)這個魔數似乎也預示着日後“Java”這個商標名稱的出現

主、次版本號(2個字節)

緊接着魔數的4個字節存儲的是Class文件的版本號:第5和第6個字節是次版本號(Minor Version),第7和第8個字節是主版本號(Major Version)

常量池

由於常量池中常量的數量是不固定的,所以在常量池的入口需要放置一項u2類型的數據,代表常量池容量計數值(constant_pool_count)

  • 被模塊導出或者開放的包(Package)
  • 類和接口的全限定名(Fully Qualified Name)
  • 字段的名稱和描述符(Descriptor)
  • 方法的名稱和描述符
  • 方法句柄和方法類型(Method Handle、Method Type、Invoke Dynamic)
  • 動態調用點和動態常量(Dynamically-Computed Call Site、Dynamically-Computed Constant

訪問標誌(2個字節)

這個標誌用於識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義爲public類型;是否定義爲abstract類型;如果是類的話,是否被聲明爲final;等等

類索引、父類索引與接口索引集合 (需要確定)

類索引(this_class)和父類索引(super_class)都是一個u2類型的數據,而接口索引集合(interfaces)是一組u2類型的數據的集合,Class文件中由這三項數據來確定該類型的繼承關係。類索引用於確定這個類的全限定名,父類索引用於確定這個類的父類的全限定名。由於Java語言不允許多重繼承,所以父類索引只有一個,除了java.lang.Object之外,所有的Java類都有父類,因此除了java.lang.Object外,所有Java類的父類索引都不爲0。接口索引集合就用來描述這個類實現了哪些接口,這些被實現的接口將按implements關鍵字(如果這個Class文件表示的是一個接口,則應當是extends關鍵字)後的接口順序從左到右排列在接口索引集合中。


類索引、父類索引和接口索引集合都按順序排列在訪問標誌之後,類索引和父類索引用兩個u2類型的索引值表示,它們各自指向一個類型爲CONSTANT_Class_info的類描述符常量,通過CONSTANT_Class_info類型的常量中的索引值可以找到定義在CONSTANT_Utf8_info類型的常量中的全限定名字符串。

字段表

字段表(field_info)用於描述接口或者類中聲明的變量。Java語言中的“字段”(Field)包括類級變量以及實例級變量,但不包括在方法內部聲明的局部變量
字段可以包括的修飾符有字段的作用域(public、private、protected修飾符)、是實例變量還是類變量(static修飾符)、可變性(final)、併發可見性(volatile修飾符,是否強制從主內存讀寫)、可否被序列化(transient修飾符)、字段數據類型(基本類型、對象、數組)、字段名稱。


上述這些信息中,各個修飾符都是布爾值,要麼有某個修飾符,要麼沒有,很適合使用標誌位來表示。而字段叫做什麼名字、字段被定義爲什麼數據類型,這些都是無法固定的,只能引用常量池中的常量來描述。
image.png

方法表

如果理解了上一節關於字段表的內容,那本節關於方法表的內容將會變得很簡單。Class文件存儲格式中對方法的描述與對字段的描述採用了幾乎完全一致的方式,方法表的結構如同字段表一樣,依次包括訪問標誌(access_flags)、名稱索引(name_index)、描述符索引(descriptor_index)、屬性表集合(attributes)幾項

屬性表

屬性表(attribute_info)在前面的講解之中已經出現過數次,Class文件、字段表、方法表都可以攜帶自己的屬性表集合,以描述某些場景專有的信息。
與Class文件中其他的數據項目要求嚴格的順序、長度和內容不同,屬性表集合的限制稍微寬鬆一些,不再要求各個屬性表具有嚴格順序,並且《Java虛擬機規範》允許只要不與已有屬性名重複,任何人實現的編譯器都可以向屬性表中寫入自己定義的屬性信息,Java虛擬機運行時會忽略掉它不認識的屬性。爲了能正確解析Class文件,《Java虛擬機規範》最初只預定義了9項所有Java虛擬機實現都應當能識別的屬性,而在最新的《Java虛擬機規範》的Java SE 12版本中,預定義屬性已經增加到29項,這些屬性具體見表6-13。後文中將對這些屬性中的關鍵的、常用的部分進行講解。


1.Code屬性
Java程序方法體裏面的代碼經過Javac編譯器處理之後,最終變爲字節碼指令存儲在Code屬性內。Code屬性出現在方法表的屬性集合之中,但並非所有的方法表都必須存在這個屬性,譬如接口或者抽象類中的方法就不存在Code屬性,如果方法表有Code屬性存在,那麼它的結構將如表6-15所示。


如果大家注意到javap中輸出的“Args_size”的值,可能還會有疑問:這個類有兩個方法——實例構造器()和inc(),這兩個方法很明顯都是沒有參數的,爲什麼Args_size會爲1?而且無論是在參數列表裏還是方法體內,都沒有定義任何局部變量,那Locals又爲什麼會等於1?如果有這樣疑問的讀者,大概是忽略了一條Java語言裏面的潛規則:在任何實例方法裏面,都可以通過“this”關鍵字訪問到此方法所屬的對象。這個訪問機制對Java程序的編寫很重要,而它的實現非常簡單,僅僅是通過在Javac編譯器編譯的時候把對this關鍵字的訪問轉變爲對一個普通方法參數的訪問,然後在虛擬機調用實例方法時自動傳入此參數而已。因此在實例方法的局部變量表中至少會存在一個指向當前對象實例的局部變量,局部變量表中也會預留出第一個變量槽位來存放對象實例的引用,所以實例方法參數值從1開始計算。這個處理只對實例方法有效,如果代碼清單6-1中的inc()方法被聲明爲static,那Args_size就不會等於1而是等於0了。


2.Exceptions屬性
3.LineNumberTable屬性
LineNumberTable屬性用於描述Java源碼行號與字節碼行號(字節碼的偏移量)之間的對應關係。它並不是運行時必需的屬性,但默認會生成到Class文件之中,可以在Javac中使用-g:none或-g:lines選項來取消或要求生成這項信息。如果選擇不生成LineNumberTable屬性,對程序運行產生的最主要影響就是當拋出異常時,堆棧中將不會顯示出錯的行號,並且在調試程序的時候,也無法按照源碼行來設置斷點。

4.LocalVariableTable及LocalVariableTypeTable屬性
LocalVariableTable屬性用於描述棧幀中局部變量表的變量與Java源碼中定義的變量之間的關係,它也不是運行時必需的屬性,但默認會生成到Class文件之中,可以在Javac中使用-g:none或-g:vars選項來取消或要求生成這項信息。如果沒有生成這項屬性,最大的影響就是當其他人引用這個方法時,所有的參數名稱都將會丟失,譬如IDE將會使用諸如arg0、arg1之類的佔位符代替原有的參數名,這對程序運行沒有影響,但是會對代碼編寫帶來較大不便,而且在調試期間無法根據參數名稱從上下文中獲得參數值。LocalVariableTable屬性的結構如表6-19所示。

5.SourceFile及SourceDebugExtension屬性
6.ConstantValue屬性
7.InnerClasses屬性
InnerClasses屬性用於記錄內部類與宿主類之間的關聯。如果一個類中定義了內部類,那編譯器將會爲它以及它所包含的內部類生成InnerClasses屬性
8.Deprecated及Synthetic屬性
9.StackMapTable屬性
10.Signature屬性
則Signature屬性會爲它記錄泛型簽名信息。之所以要專門使用這樣一個屬性去記錄泛型類型,是因爲Java語言的泛型採用的是擦除法實現的僞泛型,字節碼(Code屬性)中所有的泛型信息編譯(類型變量、參數化類型)在編譯之後都通通被擦除掉。使用擦除法的好處是實現簡單(主要修改Javac編譯器,虛擬機內部只做了很少的改動)、非常容易實現Backport,運行期也能夠節省一些類型所佔的內存空間。但壞處是運行期就無法像C#等有真泛型支持的語言那樣,將泛型類型與用戶定義的普通類型同等對待,例如運行期做反射時無法獲得泛型信息。Signature屬性就是爲了彌補這個缺陷而增設的,現在Java的反射API能夠獲取的泛型類型,最終的數據來源也是這個屬性。
11.BootstrapMethods屬性
12.MethodParameters屬性
13.模塊化相關屬性
14.運行時註解相關屬性

字節碼指令

對於大部分與數據類型相關的字節碼指令,它們的操作碼助記符中都有特殊的字符來表明專門爲哪種數據類型服務:i代表對int類型的數據操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助記符中沒有明確指明操作類型的字母,例如arraylength指令,它沒有代表數據類型的特殊字符,但操作數永遠只能是一個數組類型的對象。還有另外一些指令,例如無條件跳轉指令goto則是與數據類型無關的指令

大部分指令都沒有支持整數類型byte、char和short,甚至沒有任何指令支持boolean類型。編譯器會在編譯期或運行期將byte和short類型的數據帶符號擴展(Sign-Extend)爲相應的int類型數據,將boolean和char類型數據零位擴展(Zero-Extend)爲相應的int類型數據。與之類似,在處理boolean、byte、short和char類型的數組時,也會轉換爲使用對應的int類型的字節碼指令來處理。因此,大多數對於boolean、byte、short和char類型數據的操作,實際上都是使用相應的對int類型作爲運算類型(Computational Type)來進行

加載和存儲指令

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

運算指令

  • 加法指令: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虛擬機直接支持(即轉換時無須顯式的轉換指令)以下數值類型的寬化類型轉換(Widening Numeric Conversion,即小範圍類型向大範圍類型的安全轉換):
·int類型到long、float或者double類型
·long類型到float、double類型
·float類型到double類型
與之相對的,處理窄化類型轉換(Narrowing Numeric Conversion)時,就必須顯式地使用轉換指令來完成,這些轉換指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化類型轉換可能會導致轉換結果產生不同的正負號、不同的數量級的情況,轉換過程很可能會導致數值的精度丟失。

對象創建與訪問指令

  • 創建類實例的指令:new
  • 創建數組的指令:newarray、anewarray、multianewarray
  • 訪問類字段(static字段,或者稱爲類變量)和實例字段(非static字段,或者稱爲實例變量)的指令:getfield、putfield、getstatic、putstatic
  • 把一個數組元素加載到操作數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  • 將一個操作數棧的值儲存到數組元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
  • 取數組長度的指令:arraylength
  • 檢查類實例類型的指令:instanceof、checkcast

操作數棧管理指令

  • 如同操作一個普通數據結構中的堆棧那樣,Java虛擬機提供了一些用於直接操作操作數棧的指令,包括:
  • 將操作數棧的棧頂一個或兩個元素出棧: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

方法調用和返回指令

方法調用(分派、執行過程)將在第8章具體講解,這裏僅列舉以下五條指令用於方法調用:

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

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

異常處理指令

在Java程序中顯式拋出異常的操作(throw語句)都由athrow指令來實現

同步指令

Java虛擬機可以支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor,更常見的是直接將它稱爲“鎖”)來實現的。


方法級的同步是隱式的,無須通過字節碼指令來控制,它實現在方法調用和返回操作之中。虛擬機可以從方法常量池中的方法表結構中的ACC_SYNCHRONIZED訪問標誌得知一個方法是否被聲明爲同步方法。當方法調用時,調用指令將會檢查方法的ACC_SYNCHRONIZED訪問標誌是否被設置,如果設置了,執行線程就要求先成功持有管程,然後才能執行方法,最後當方法完成(無論是正常完成還是非正常完成)時釋放管程。在方法執行期間,執行線程持有了管程,其他任何線程都無法再獲取到同一個管程。如果一個同步方法執行期間拋出了異常,並且在方法內部無法處理此異常,那這個同步方法所持有的管程將在異常拋到同步方法邊界之外時自動釋放。
同步一段指令集序列通常是由Java語言中的synchronized語句塊來表示的,Java虛擬機的指令集中有monitorenter和monitorexit兩條指令來支持synchronized關鍵字的語義,正確實現synchronized關鍵字需要Javac編譯器與Java虛擬機兩者共同協作支持


爲了保證在方法異常完成時monitorenter和monitorexit指令依然可以正確配對執行,編譯器會自動產生一個異常處理程序,這個異常處理程序聲明可處理所有的異常,它的目的就是用來執行monitorexit指令




運行期

內存分佈


image.png
**  JVM內存經典佈局**

類加載器

站在Java虛擬機的角度來看,只存在兩種不同的類加載器:一種是啓動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;另外一種就是其他所有的類加載器,這些類加載器都由Java語言實現,獨立存在於虛擬機外部,並且全都繼承自抽象類java.lang.ClassLoader。


站在Java開發人員的角度來看,類加載器就應當劃分得更細緻一些。自JDK 1.2以來,Java一直保持着三層類加載器、雙親委派的類加載架構,儘管這套架構在Java模塊化系統出現後有了一些調整變動,但依然未改變其主體結構,我們將在7.5節中專門討論模塊化系統下的類加載器。

  • 啓動類加載器(Bootstrap Class Loader):前面已經介紹過,這個類加載器負責加載存放在<JAVA_HOME>\lib目錄,或者被-Xbootclasspath參數所指定的路徑中存放的,而且是Java虛擬機能夠識別的(按照文件名識別,如rt.jar、tools.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機的內存中。啓動類加載器無法被Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器去處理,那直接使用null代替即可,java.lang.ClassLoader.getClassLoader()方法的代碼片段,其中的註釋和代碼實現都明確地說明了以null值來代表引導類加載器的約定規則。

  • 擴展類加載器(Extension Class Loader):這個類加載器是在類sun.misc.Launcher$ExtClassLoader中以Java代碼的形式實現的。它負責加載<JAVA_HOME>\lib\ext目錄中,或者被java.ext.dirs系統變量所指定的路徑中所有的類庫。根據“擴展類加載器”這個名稱,就可以推斷出這是一種Java系統類庫的擴展機制,JDK的開發團隊允許用戶將具有通用性的類庫放置在ext目錄裏以擴展Java SE的功能,在JDK 9之後,這種擴展機制被模塊化帶來的天然的擴展能力所取代。由於擴展類加載器是由Java代碼實現的,開發者可以直接在程序中使用擴展類加載器來加載Class文件。

  • 應用程序類加載器(Application Class Loader):這個類加載器由sun.misc.Launcher$AppClassLoader來實現。由於應用程序類加載器是ClassLoader類中的getSystem-ClassLoader()方法的返回值,所以有些場合中也稱它爲“系統類加載器”。它負責加載用戶類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。



![image.png](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4ubmxhcmsuY29tL3l1cXVlLzAvMjAyMC9wbmcvMTY4MTkxLzE1ODc0NjAzMzIwODgtMDUwZDk1YzUtODU4My00Zjk0LWFkMjctZjdmOThkNjNhMTI3LnBuZw?x-oss-process=image/format,png#align=left&display=inline&height=349&margin=[object Object]&name=image.png&originHeight=826&originWidth=1064&size=741380&status=done&style=none&width=450)
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啓動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試自己去完成加載。

類加載 什麼時候加載類

一個類型從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期將會經歷加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統稱爲連接(Linking)。

image.png

前置條件 加載類的條件

1)遇到new、getstatic、putstatic或invokestatic這四條字節碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:

  • 使用new關鍵字實例化對象的時候。
  • 讀取或設置一個類型的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候。
  • 調用一個類型的靜態方法的時候。


2)使用java.lang.reflect包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。
3)當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4)當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
5)當使用JDK 7新加入的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果爲REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,並且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。
6)當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。

驗證

驗證字節流是否符合Class文件格式的規範

  1. 文件格式的驗證
  • 是否以魔數0xCAFEBABE開頭。
  • 主、次版本號是否在當前Java虛擬機接受範圍之內。
  • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標誌)。
  • 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的數據。
  • Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
  1. 元數據的驗證

第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合《Java語言規範》的要求,這個階段可能包括的驗證點如下:

  • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
  1. 字節碼的驗證

第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過數據流分析和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型校驗完畢以後,這階段就要對類的方法體(Class文件中的Code屬性)進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行爲


  1. 符號引用驗證

最後一個階段的校驗行爲發生在虛擬機將符號引用轉化爲直接引用 [3] 的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。本階段通常需要校驗下列內容:

  • 符號引用中通過字符串描述的全限定名是否能找到對應的類。
  • 在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
  • 符號引用中的類、字段、方法的可訪問性(private、protected、public、)是否可被當前類訪問。

方法不存在會報錯: java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

準備

準備階段是正式爲類中定義的變量(即靜態變量,被static修飾的變量)分配內存並設置類變量初始值的階段,首先是這時候進行內存分配的僅包括類變量,其次是這裏所說的初始值“通常情況”下是數據類型的零值

解析

解析階段是Java虛擬機將常量池內的符號引用替換爲直接引用的過程

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定是已經加載到虛擬機內存當中的內容。各種虛擬機實現的內存佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因爲符號引用的字面量形式明確定義在《Java虛擬機規範》的Class文件格式中。
  • 直接引用(Direct References):直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中存在。

image.png
1.類或接口的解析
如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由於元數據驗證、字節碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或實現的接口。一旦這個加載過程出現了任何異常,解析過程就將宣告失敗。




2.字段解析 常量解析
要解析一個未被解析過的字段符號引用,首先將會對字段表內class_index 項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現了任何異常,都會導致字段符號引用解析的失敗。如果解析成功完成,那把這個字段所屬的類或接口用C表示,《Java虛擬機規範》要求按照如下步驟對C進行後續字段的搜索:
1)如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
2)否則,如果在C中實現了接口,將會按照繼承關係從下往上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
3)否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
4)否則,查找失敗,拋出java.lang.NoSuchFieldError異常。
如果查找過程成功返回了引用,將會對這個字段進行權限驗證,如果發現不具備對字段的訪問權限,將拋出java.lang.IllegalAccessError異常。




3.方法解析
方法解析的第一個步驟與字段解析一樣,也是需要先解析出方法表的class_index [4] 項中索引的方法所屬的類或接口的符號引用,如果解析成功,那麼我們依然用C表示這個類,接下來虛擬機將會按照如下步驟進行後續的方法搜索:
1)由於Class文件格式中類的方法和接口的方法符號引用的常量類型定義是分開的,如果在類的方法表中發現class_index中索引的C是個接口的話,那就直接拋出java.lang.IncompatibleClassChangeError異常。
2)如果通過了第一步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時候查找結束,拋出java.lang.AbstractMethodError異常。
5)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError。
最後,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,將拋出java.lang.IllegalAccessError異常。


4.接口方法解析
接口方法也是需要先解析出接口方法表的class_index [5] 項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行後續的接口方法搜索:
1)與類的方法解析相反,如果在接口方法表中發現class_index中的索引C是個類而不是接口,那麼就直接拋出java.lang.IncompatibleClassChangeError異常。
2)否則,在接口C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
3)否則,在接口C的父接口中遞歸查找,直到java.lang.Object類(接口方法的查找範圍也會包括Object類中的方法)爲止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4)對於規則3,由於Java的接口允許多重繼承,如果C的不同父接口中存有多個簡單名稱和描述符都與目標相匹配的方法,那將會從這多個方法中返回其中一個並結束查找,《Java虛擬機規範》中並沒有進一步規則約束應該返回哪一個接口方法。但與之前字段查找類似地,不同發行商實現的Javac編譯器有可能會按照更嚴格的約束拒絕編譯這種代碼來避免不確定性。
5)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

初始化

直到初始化階段,Java虛擬機才真正開始執行類中編寫的Java程序代碼,將主導權移交給應用程序,初始化階段就是執行類構造器()方法的過程。()並不是程序員在Java代碼中直接編寫的方法,它是Javac編譯器的自動生成物,但我們非常有必要了解這個方法具體是如何產生的,以及()方法執行過程中各種可能會影響程序運行行爲的細節

  • ()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問

  • ()方法與類的構造函數(即在虛擬機視角中的實例構造器()方法)不同,它不需要顯式地調用父類構造器,Java虛擬機會保證在子類的()方法執行前,父類的()方法已經執行完畢。因此在Java虛擬機中第一個被執行的()方法的類型肯定是java.lang.Object。

  • 由於父類的()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作


  • Java虛擬機必須保證一個類的()方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那麼只會有其中一個線程去執行這個類的()方法,其他線程都需要阻塞等待,直到活動線程執行完畢()方法。如果在一個類的()方法中有耗時很長的操作,那就可能造成多個進程阻塞 [2]

Java對象在堆中的佈局

image.png

運行時棧幀結構

每一個棧幀都包括了局部變量表、操作數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯Java程序源碼的時候,棧幀中需要多大的局部變量表,需要多深的操作數棧就已經被分析計算出來,並且寫入到方法表的Code屬性之中 。換言之,一個棧幀需要分配多少內存,並不會受到程序運行期變量數據的影響,而僅僅取決於程序源碼和具體的虛擬機實現的棧內存佈局形式。


image.png

局部變量表

局部變量表(Local Variables Table)是一組變量值的存儲空間,用於存放方法參數和方法內部定義的局部變量。在Java程序被編譯爲Class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需分配的局部變量表的最大容量。


局部變量表的容量以變量槽(Variable Slot)爲最小單位,《Java虛擬機規範》中並沒有明確指出一個變量槽應占用的內存空間大小,只是很有導向性地說到每個變量槽都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的數據


爲了儘可能節省棧幀耗用的內存空間,局部變量表中的變量槽是可以重用的,方法體中定義的變量,其作用域並不一定會覆蓋整個方法體,如果當前字節碼PC計數器的值已經超出了某個變量的作用域,那這個變量對應的變量槽就可以交給其他變量來重用


placeholder能否被回收的根本原因就是:局部變量表中的變量槽是否還存有關於placeholder數組對象的引用。第一次修改中,代碼雖然已經離開了placeholder的作用域,但在此之後,再沒有發生過任何對局部變量表的讀寫操作,placeholder原本所佔用的變量槽還沒有被其他變量所複用,所以作爲GC Roots一部分的局部變量表仍然保持着對它的關聯。這種關聯沒有被及時打斷,絕大部分情況下影響都很輕微。但如果遇到一個方法,其後面的代碼有一些耗時很長的操作,而前面又定義了佔用了大量內存但實際上已經不會再使用的變量,手動將其設置爲null值(用來代替那句int a=0,把變量對應的局部變量槽清空)便不見得是一個絕對無意義的操作,這種操作可以作爲一種在極特殊情形(對象佔用內存大、此方法的棧幀長時間不能被回收、方法調用次數達不到即時編譯器的編譯條件)下的“奇技”來使用。

操作數棧

操作數棧(Operand Stack)也常被稱爲操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候被寫入到Code屬性的max_stacks數據項之中。操作數棧的每一個元素都可以是包括long和double在內的任意Java數據類型。32位數據類型所佔的棧容量爲1,64位數據類型所佔的棧容量爲2。Javac編譯器的數據流分析工作保證了在方法執行的任何時候,操作數棧的深度都不會超過在max_stacks數據項中設定的最大值。

動態鏈接

每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態連接(Dynamic Linking)。我們知道Class文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池裏指向方法的符號引用作爲參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就被轉化爲直接引用,這種轉化被稱爲靜態解析。另外一部分將在每一次運行期間都轉化爲直接引用,這部分就稱爲動態連接

方法返回地址

當一個方法開始執行後,只有兩種方式退出這個方法。
第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱爲調用者或者主調方法),方法是否有返回值以及返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱爲“正常調用完成”(Normal Method Invocation Completion)。
另外一種退出方式是在方法執行的過程中遇到了異常,並且這個異常沒有在方法體內得到妥善處理。無論是Java虛擬機內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱爲“異常調用完成(Abrupt Method Invocation Completion)”。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者提供任何返回值的

方法調用

所有的Java操作都可以看成是方法之間的相互調用

解析

所有方法調用的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化爲直接引用,這種解析能夠成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要有靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問


相關的指令

  • invokestatic。用於調用靜態方法。
  • invokespecial。用於調用實例構造器()方法、私有方法和父類中的方法。
  • invokevirtual。用於調用所有的虛方法。
  • invokeinterface。用於調用接口方法,會在運行時再確定一個實現該接口的對象。


靜態解析-非虛方法
只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中確定唯一的調用版本,Java語言裏符合這個條件的方法共有靜態方法、私有方法、實例構造器、父類方法4種,再加上被final修飾的方法(儘管它使用invokevirtual指令調用),這5種方法調用會在類加載的時候就可以把符號引用解析爲該方法的直接引用。

分派

而另一種主要的方法調用形式:分派(Dispatch)調用則要複雜許多,它可能是靜態的也可能是動態的,按照分派依據的宗量數可分爲單分派和多分派 [1] 。這兩類分派方式兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況



1.靜態分派-重載的實現
虛擬機(或者準確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型作爲判定依據的。由於靜態類型在編譯期可知,所以在編譯階段,Javac編譯器就根據參數的靜態類型決定了會使用哪個重載版本,所有依賴靜態類型來決定方法執行版本的分派動作,都稱爲靜態分派。靜態分派的最典型應用表現就是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的,這點也是爲何一些資料選擇把它歸入“解析”而不是“分派”的原因。


2.動態分派
我們接下來看一下Java語言裏動態分派的實現過程,它與Java語言多態性的另外一個重要體現 ——重寫(Override)有着很密切的關聯
image.png
指令爲 invokevirtual
1)找到操作數棧頂的第一個元素所指向的對象的實際類型 ,記作C。
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;不通過則返回java.lang.IllegalAccessError異常。
3)否則,按照繼承關係從下往上依次對C的各個父類進行第二步的搜索和驗證過程。
4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
正是因爲invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令並不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據方法接收者的實際類型來選擇方法版本,這個過程就是Java語言中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱爲動態分

3.虛擬機動態分派的實現
動態分派是執行非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在接收者類型的方法元數據中搜索合適的目標方法,因此,Java虛擬機實現基於執行性能的考慮,真正運行時一般不會如此頻繁地去反覆搜索類型元數據。面對這種情況,一種基礎而且常見的優化手段是爲類型在方法區中建立一個虛方法表(Virtual Method Table,也稱爲vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Interface Method Table,簡稱itable),使用虛方法表索引來代替元數據查找以提高性能


image.png




這個符號引用包含了該方法定義在哪個具體類型之中、方法的名字以及參數順序、參數類型和方法返回值等信息,通過這個符號引用,Java虛擬機就可以翻譯出該方法的直接引用。而ECMAScript等動態類型語言與Java有一個核心的差異就是變量obj本身並沒有類型,變量obj的值才具有類型,所以編譯器在編譯時最多隻能確定方法名稱、參數、返回值這些信息,而不會去確定方法所在的具體類型(即方法接收者不固定)。“變量無類型而變量值纔有類型”這個特點也是動態類型語言的一個核心特徵。

反射和方法句柄

·Reflection和MethodHandle機制本質上都是在模擬方法調用,但是Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用。在MethodHandles.Lookup上的3個方法findStatic()、findVirtual()、findSpecial()正是爲了對應於invokestatic、invokevirtual(以及invokeinterface)和invokespecial這幾條字節碼指令的執行權限校驗行爲,而這些底層細節在使用Reflection API時是不需要關心的。
·Reflection中的java.lang.reflect.Method對象遠比MethodHandle機制中的java.lang.invoke.MethodHandle對象所包含的信息來得多。前者是方法在Java端的全面映像,包含了方法的簽名、描述符以及方法屬性表中各種屬性的Java端表示方式,還包含執行權限等的運行期信息。而後者僅包含執行該方法的相關信息。用開發人員通俗的話來講,Reflection是重量級,而MethodHandle是輕量級。
·由於MethodHandle是對字節碼的方法指令調用的模擬,那理論上虛擬機在這方面做的各種優化(如方法內聯),在MethodHandle上也應當可以採用類似思路去支持(但目前實現還在繼續完善中),而通過反射去調用方法則幾乎不可能直接去實施各類調用點優化措施。


MethodHandle與Reflection除了上面列舉的區別外,最關鍵的一點還在於去掉前面討論施加的前提“僅站在Java語言的角度看”之後:Reflection API的設計目標是隻爲Java語言服務的,而MethodHandle則設計爲可服務於所有Java虛擬機之上的語言,其中也包括了Java語言而已,而且Java在這裏並不是主角。

垃圾回收

判斷哪些對象是垃圾:

1.引用計數法

在對象中添加一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器爲零的對象就是不可能再被使用的
缺點 : 無法解決對象之間循環引用的問題

2.可達性分析算法

“GC Roots”的根對象作爲起始節點集,從這些節點開始,根據引用關係向下搜索,搜索過程所走過的路徑稱爲“引用鏈”(Reference Chain),如果某個對象到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個對象不可達時,則證明此對象是不可能再被使用的


判定爲GCROOT的對象

  • 在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。
  • 在方法區中常量引用的對象,譬如字符串常量池(String Table)裏的引用。
  • Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfM emoryError)等,還有系統類加載器。
  • 所有被同步鎖(synchronized關鍵字)持有的對象。
  • 反映Java虛擬機內部情況的JM XBean、JVM TI中註冊的回調、本地代碼緩存等。


四種引用

  • 強引用是最傳統的“引用”的定義,是指在程序代碼之中普遍存在的引用賦值,即類似“Objectobj=new Object()”這種引用關係。無論任何情況下,只要強引用關係還存在,垃圾收集器就永遠不會回收掉被引用的對象。
  • 軟引用是用來描述一些還有用,但非必須的對象。只被軟引用關聯着的對象,在系統將要發生內存溢出異常前,會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的內存,纔會拋出內存溢出異常。在JDK 1.2版之後提供了SoftReference類來實現軟引用。
  • 弱引用也是用來描述那些非必須對象,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生爲止。當垃圾收集器開始工作,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK 1.2版之後提供了WeakReference類來實現弱引用。
  • 虛引用也稱爲“幽靈引用”或者“幻影引用”,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛

回收方法區


方法區的垃圾收集主要回收兩部分內容:廢棄的常量和不再使用的類型。回收廢棄常量與回收


■新生代收集(M inor GC/Young GC):指目標只是新生代的垃圾收集。
■老年代收集(M ajor GC/Old GC):指目標只是老年代的垃圾收集。目前只有CM S收集器會有單

垃圾回收算法

1.標記清除

image.png
缺點 
第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對象數量增長而降低;


第二個是內存空間的碎片化問題,標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致當以後在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作

2.標記-複製算法

缺點 50%的空間

3.標記-整理算法

第一部分的情況下,整理內存結構,減少內存碎片的出現

垃圾回收器 吞吐量 和 內存 和 延遲

image.png

Serial收集器

單線程工作的收集器,存在Stop The World  舉個例子:**運行一個小時就會暫停響應五分鐘,**你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待着,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完? 新生代採取複製算法,老年代採取標記整理算法,適用場景 JVM的客戶端模式、桌面客戶端應用
優點:簡單而高效 內存消耗最小
缺點:存在可怕的stop the world


image.png

ParNew收集器

Serial收集器多線程版本,目前只有它能與CMS收集器配合工作。ParNew收集器是激活CMS後(使用-XX:+UseConcMarkSweepGC選項)的默認新生代收集器,新生代適用複製算法,老年代採用標記整理算法


image.png

·並行(Parallel):並行描述的是多條垃圾收集器線程之間的關係,說明同一時間有多條這樣的線程在協同工作,通常默認此時用戶線程是處於等待狀態。
·併發(Concurrent):併發描述的是垃圾收集器線程與用戶線程之間的關係,說明同一時間垃圾收集器線程與用戶線程都在運行。由於用戶線程並未被凍結,所以程序仍然能響應服務請求,但由於垃圾收集器線程佔用了一部分系統資源,此時應用程序的處理的吞吐量將受到一定影響

Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同樣是基於標記-複製算法實現的收集器,也是能夠並行收集的多線程收集器,Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput),就是可以控制垃圾回收的時間(我想在100毫秒內完成垃圾回收)
image.png
擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。這種調節方式稱爲垃圾收集的自適應的調節策略(GC Ergonomics)

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用標記-整理算法。這個收集器的主要意義也是供客戶端模式下的HotSpot虛擬機使用,新生代採用複製算法,老年代採用標記整理算法image.png

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多線程併發收集,基於標記-整理算法實現。在注重吞吐量或者處理器資源較爲稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器這個組合
image.png

CMS收集器

CMS等收集器的關注點是**儘可能地縮短垃圾收集時用戶線程的停頓時間,**目前很大一部分的Java應用集中在互聯網網站或者基於瀏覽器的B/S系統的服務端上,這類應用通常都會較爲關注服務的響應速度,希望系統停頓時間儘可能短,以給用戶帶來良好的交互體驗。CMS收集器就非常符合這類應用的需求,採用標記-清除算法
核心步驟
1)初始標記(CMS initial mark) stop the word
標記一下GC Roots能直接關聯到的對象,速度很快
2)併發標記(CMS concurrent mark)併發
從GC Roots的直接關聯對象開始遍歷整個對象圖的過程
3)重新標記(CMS remark)stop the word
爲了修正併發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄
4)併發清除(CMS concurrent sweep)併發
清理刪除掉標記階段判斷的已經死亡的對象,由於不需要移動存活對象
image.png

缺點

1.處理器資源敏感,佔用一部分線程而導致應用程序變慢,,降低總吞吐量,默認啓動的回收線程數是(處理器核心數量+3)/4
2.在併發標記和併發清理階段,用戶線程是還在繼續運行的,程序在運行自然就還會伴隨有新的垃圾對象不斷產生,但這一部分垃圾對象是出現在標記過程結束以後,CMS無法在當次收集中處理掉它們,只好留待下一次垃圾收集時再清理掉。這一部分垃圾就稱爲“浮動垃圾
3.CMS運行期間預留的內存無法滿足程序分配新對象的需要,就會出現一次“併發失敗”(Concurrent Mode Failure),這時候虛擬機將不得不啓動後備預案:凍結用戶線程的執行,臨時啓用Serial Old收集器來重新進行老年代的垃圾收集,但這樣停頓時間就很長了,用戶應在生產環境中根據實際應用情況來權衡設置
4.採用標記-清除算法,產生空間碎片

Garbage First收集器

https://docs.oracle.com/en/java/javase/14/gctuning/garbage-first-g1-garbage-collector1.html#GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573
開創了收集器面向局部收集的設計思路和基於Region的內存佈局形式,HotSpot虛擬機提出了“統一垃圾收集器接口” [2] ,將內存回收的“行爲”與“實現”進行分離,CMS以及其他收集器都重構成基於這套接口的一種實現,關注點在於 可控制的垃圾收集時間,(不超過N毫秒的目標)




基於Region的內存佈局
把連續的Java堆劃分爲多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region採用不同的策略去處理
image.png

核心步驟

  • 初始標記 (Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS指針的值,讓下一階段用戶線程併發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際並沒有額外的停頓。
  • 併發標記 (Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆裏的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序併發執行。當對象圖掃描完成以後,還要重新處理SATB記錄下的在併發時有引用變動的對象。
  • 最終標記 (Final Marking):對用戶線程做另一個短暫的暫停,用於處理併發階段結束後仍遺留下來的最後那少量的SATB記錄。
  • 篩選回收 (Live Data Counting and Evacuation):負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region構成回收集,然後把決定回收的那一部分Region的存活對象複製到空的Region中,再清理掉整個舊Region的全部空間。這裏的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程並行完成的。

image.png

到此我們的對象就走完了, 完整的一個Java生命旅程就結束了,這裏面值得研究的東西很多,本文只是將其串起來,梳理一下知識脈絡,幫助你更好的理解


由於本人才疏學淺,有錯誤的地方歡迎指出 

參考資料

1.《深入理解Java虛擬機-第三版》

2.《The Java® VirtualMachine Specification》

3.《碼出高效》

4.《編譯原理》

5. https://www.slideshare.net/mysqlops/java-program-inaction

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章