Java虛擬機器:記憶體管理與執行引擎

KiteRunner24發表於2018-03-26

一、Java技術體系

Sun官方所定義的Java技術體系包括以下幾個組成部分:

  • Java程式設計語言
  • 各種硬體平臺上的Java虛擬機器
  • Class檔案格式
  • Java API類庫
  • 來自商業機構和開源社群的第三方類庫

JDK(Java Development Kit) —— 包括Java程式設計語言、Java虛擬機器、Java API類庫。JDK是用於支援Java程式開發的最小環境。

JRE(Jave Runtime Environment) —— 包括Java API類庫中的Java SE API子集、Java虛擬機器。JRE是支援Java程式執行的標準環境。

下圖展示了Java技術體系所包含的內容,以及JDK和JRE所涵蓋的範圍:

Java虛擬機器:記憶體管理與執行引擎

按照技術所服務的領域來分,Java技術體系可以分為四個平臺,分別是:

  • Java Card:支援一些Java小程式(Applet)執行在小記憶體裝置(如智慧卡)上的平臺。
  • Java ME(Micro Edition):支援Java程式執行在移動終端(手機、PDA)上的平臺,對Java API有所精簡,並加入了針對移動終端的支援。
  • Java SE(Standard Edition):支援面向桌面級應用(如Windows下的應用程式)的Java平臺,提供了完整的Java核心API。
  • Java EE(Enterprise Edition):支援使用多層架構的企業應用的Java平臺,除了提供Java SE API之外,還對其做了大量的擴充並提供了相關的部署支援。

二、Java技術未來

1. 模組化

模組化是解決應用系統與技術平臺越來越複雜、越來越龐大問題的一個重要途徑。站在整個軟體工業化的高度來看,模組化是建立各種功能的標準件的前提。最近幾年的OSGi技術的迅速發展、各個廠商在JCP中對模組化規範的激烈鬥爭,都能充分說明模組化技術的迫切和重要。

2. 混合語言

當單一的Java開發已經無法滿足當前軟體的複雜需求時,越來越多基於Java虛擬機器的語言開發被應用到軟體專案中,Java平臺上的多語言混合程式設計正成為主流,每種語言都可以針對自己擅長的方面更好地解決問題。試想一下,在一個專案之中,並行處理用Clojure語言編寫,展示層使用JRuby/Rails,中間層使用Java,每個應用層都將使用不同的程式語言來完成,而且,介面對每一層的開發者都是透明的,各種語言之間的互動不存在任何困難,就像使用自己語言的原生API一樣方便,因為它們最終都執行在一個虛擬機器之上。因此,整個JVM專案開始推動Java虛擬機器從“Java語言的虛擬機器”向“多語言虛擬機器”的方向發展。

3. 多核並行

如今,CPU硬體的發展方向已經從高頻率轉變為多核心,隨著多核時代的來臨,軟體開發越來越關注並行程式設計的領域。Fork/Join模式是處理並行程式設計的一個經典方法,通過利用Fork/Join模式,我們能夠更加順暢地過渡到多核時代。

在Java8中,將會提供Lambda支援,這將會極大地改善目前Java語言不適合函數語言程式設計的現狀。另外,在平行計算中必須提及的還有Sumatra專案,其主要關注為Java提供使用GPU和APU運算能力的工具。在JDK外圍,也出現了專為滿足平行計算需求的計算框架,如Apache的Hadoop Map/Reduce等。

4. 進一步豐富語法

Java 5曾經對Java語法進行了一次擴充,這次擴充加入了自動裝箱、泛型、動態註解、列舉、可變長引數、遍歷迴圈等語法,而後,每一次Java版本的釋出,都會進一步豐富Java語言的語法特性,包括Java 8中的Lambda表示式。

5. 64位虛擬機器

隨著硬體的進一步發展,計算機終究會完全過渡到64位的時代,這是一件毫無疑問的事情,主流的虛擬機器應用也終究會從32位發展到64位,而Java虛擬機器對64位的支援也將會進一步完善。

二、Java記憶體管理機制

Java與C++之間有一堵由記憶體動態分配和垃圾收集技術所圍成的高牆,牆外面的人想進去,牆裡面的人卻想出來。

1. 執行時資料區域

Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干不同的資料區域。這些區域都有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程式的啟動而存在,有些區域則依賴於使用者執行緒的啟動和結束而建立和銷燬。

Java虛擬機器所管理的記憶體包括以下幾個執行時資料區域:

Java虛擬機器:記憶體管理與執行引擎

1.1 程式計數器

程式計數器(Program Counter Register)是一塊較小的記憶體空間,它可以看做是當前執行緒所執行的位元組碼的行號指示器

在虛擬機器的概念模型裡,位元組碼直譯器工作時就是通過改變程式計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴於程式計數器來完成。

由於Java虛擬機器的多執行緒是通過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)都只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為執行緒私有的記憶體

如果執行緒正在執行的是一個Java方法,程式計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是Native方法,則程式計數器的值為空(Undefined)。

程式計數器是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域。

1.2 Java虛擬機器棧

與程式計數器一樣,Java虛擬機器棧(Java Virtual Machine Stack)也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame,棧幀是方法執行時的基礎資料結構)用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

對於C/C++等程式來說,其記憶體管理常常分為棧、堆等。對於Java,棧即指代虛擬機器棧,或者說是虛擬機器棧中區域性變數表部分。

區域性變數表存放了編譯期可知的各種基本資料型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference型別,它不等同於物件本身,可能是一個指向物件起始地址的引用地址,也可能是指向一個代表物件的控制程式碼或其他與此物件相關的位置)和returnAddress型別(指向了一條位元組碼指令的地址)。

區域性變數表所需的記憶體空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的區域性變數空間是完全確定的,在方法執行期間不會改變區域性變數表的大小。

可以通過 -Xss 這個虛擬機器引數來指定一個程式的 Java 虛擬機器棧記憶體大小:

java -Xss=512M HackTheJava

該區域可能丟擲以下異常:

  • 當執行緒請求的棧深度超過最大值,會丟擲StackOverflowError 異常;
  • 棧進行動態擴充套件時如果無法申請到足夠記憶體,會丟擲OutOfMemoryError 異常。

在Java虛擬機器規範中,對這個區域規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果虛擬機器棧可以動態擴充套件,如果擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常

1.3 本地方法棧

本地方法棧(Native Method Stack)與虛擬機器棧所發揮的作用是非常相似的,它們的區別是虛擬機器棧為虛擬機器執行Java方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的Native方法服務。與虛擬機器棧一樣,本地方法棧也會丟擲StackOverflowError異常和OutOfMemoryError異常。

1.4 Java堆

對於大多數應用來說,Java堆(Java Heap)是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項。在JVM中,幾乎所有的物件例項都在這裡分配記憶體。Java虛擬機器規範中的描述是:所有的物件例項以及陣列都要在堆上分配,但是隨著JIT編譯器的發展和逃逸技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也變得不是那麼絕對了

Java堆是垃圾收集器管理的主要區域,因此,Java堆也被稱為“GC堆”(Garbage Collected Heap)。

現代的垃圾收集器基本都是採用分代收集演算法,該演算法的思想是針對不同的物件採取不同的垃圾回收演算法,因此虛擬機器把Java堆分成以下三塊:

  • 新生代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Generation)

當一個物件被建立時,它首先進入新生代,之後有可能被轉移到老年代中。新生代存放著大量的生命很短的物件,因此新生代在三個區域中垃圾回收的頻率最高。為了更高效的進行垃圾回收,把新生代繼續劃分為以下三個空間:

  • Eden
  • From Survivor
  • To Survivor

Java虛擬機器:記憶體管理與執行引擎

從記憶體分配的角度來看,執行緒共享的Java堆中可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer, TLAB)。根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間,只要邏輯上是連續的即可。在實現時,既可以實現成固定大小的,也可以是可擴充套件的,如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError異常。

可以通過 -Xms 和 -Xmx 兩個虛擬機器引數來指定一個程式的 Java 堆記憶體大小,第一個引數設定最小值,第二個引數設定最大值。

java -Xms=1M -XmX=2M HackTheJava

1.5 方法區

方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做Non-Heap(非堆),目的應該與Java堆區分開來。

Java虛擬機器規範對方法區的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾回收。相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如同永久代名字一樣永久存在。該區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝。

根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。

執行時常量池(Runtime Costant Pool)是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池(Constant Pool),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。

執行時常量池相對於Class檔案常量池的另外一個重要特徵是具備動態性。Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class檔案常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,如String類的intern()方法。

既然執行時常量區是方法區的一部分,當常量池無法申請到記憶體時會丟擲OutOfMemoryError異常。

1.6 直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域。

在Java4中新加入的NIO類,其引入了一種基於通道與緩衝區的IO方式,它可以使用Native函式庫直接分配堆外記憶體,然後通過一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的應用進行操作。這樣能在一些場景中顯著提高效能,在一定程度上能避免在Java堆和Native堆中來回複製資料。

2. JVM物件探祕

在本部分,我們將深入探討HotSpot虛擬機器在Java堆中物件分配、佈局和訪問的全過程。

2.1 物件的建立

在HotSpot虛擬機器中,物件的建立過程分為五個步驟:

Java虛擬機器:記憶體管理與執行引擎

步驟一

當虛擬機器遇到一條new指令時,首先會去檢查new指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,那必須先執行相應的類載入過程

步驟二

在類載入檢查通過後,虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配記憶體空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。分配方式有:

  1. 指標碰撞(Bump the Pointer):假設Java堆中記憶體是絕對規整的,即所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,指標碰撞的記憶體分配方式就是把作為分界點的指標向空閒空間挪動一段與物件大小相等的距離即可

  2. 空閒列表(Free List):如果Java堆中的記憶體不是規整的,即已使用的記憶體和空閒的記憶體相互交錯,那麼就無法使用指標碰撞了。此時,虛擬機器就必須維護一個列表,記錄哪些記憶體塊是可用的,於是,空閒列表的記憶體分配方式就是從該列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄

選擇哪種記憶體分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配演算法是指標碰撞,而使用CMS這種基於Mark-Sweep演算法的收集器時,通常採用空閒列表。

另外,在併發環境下,記憶體分配方式面臨執行緒安全問題。解決這個問題有兩種方案:

  1. 對分配記憶體空間的動作進行同步處理——實際上虛擬機器採用CAS結合失敗重試的方法來保證更新操作的原子性。

  2. 把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝區(Thread Local Allocation Buffer,TLAB)

步驟三

記憶體分配完成後,虛擬機器需要將分配的記憶體空間初始化為零值(不包括物件頭)。這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值

步驟四

接下來,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中。

步驟五

在上面的工作都完成之後,從虛擬機器的視角看,一個新的物件已經產生了。但是,從Java程式設計師的視角來看,物件建立才剛剛開始——<init>方法還沒有執行,所有欄位都還為零。

因此,一般來說,執行new命令之後,會接著執行<init>方法,把物件按照程式設計師的意願進行初始化,這樣,一個真正可用的物件才算完全產生出來。

2.2 物件記憶體佈局

在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以劃分為3個區域:

  • 物件頭(Object Header)
  • 例項資料(Instance Data)
  • 對齊填充(Padding)

物件頭資訊

HotSpot虛擬機器的物件頭包括兩部分資訊:

  • 第一部分:用於儲存物件自身的執行時資料,如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖等,官方稱之為“Mark Word”。該部分資料長度在32位和64位虛擬機器中分別為32位和64位。Mark Word被設計成一個非固定的資料結構,以便在極小的空間記憶體儲儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。下表是32位HotSpot虛擬機器物件頭Mark Word。
儲存內容 標誌位 狀態
物件雜湊碼、物件分代年齡 01 未鎖定
指向鎖記錄的指標 00 輕量級鎖定
指向重量級鎖的指標 10 膨脹(重量級鎖定)
空,不需要記錄資訊 11 GC標記
偏向執行緒ID、偏向時間戳、物件分代年齡 01 可偏向
  • 第二部分:型別指標,即物件指向它的類後設資料的指標,虛擬機器通過該指標來確定這個物件是哪個類的例項。另外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。

例項資料

例項資料部分是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄下來。

該部分的儲存順序會受到虛擬機器分配策略引數(FieldsAllocationStyle)和欄位在Java原始碼中定義順序影響。

  • HotSpot虛擬機器預設的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。從分配策略可以看出,相同寬度的欄位總是分配在一起。在滿足該前提條件下,在父類中定義的變數會出現在子類之前。

對齊填充

對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。當物件例項資料部分沒有對齊時,需要通過對齊填充來補全。

3. OutOfMemoryError異常

3.1 Java堆溢位

Java堆配置引數:-Xmx-Xms

Java堆用於儲存物件例項,只要不斷地建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件數量到達最大堆的容量限制後就會產生記憶體溢位異常。

測試程式碼:

public class HeapOOM {
    static classn OOMObject {
    }

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();

        while (true) {
            list.add(new OOMObject);
         }
    }
}

3.2 虛擬機器棧和本地方法棧溢位

Java虛擬機器棧配置引數:-Xss

關於虛擬機器棧和本地方法棧,在Java虛擬機器規範中描述了兩種異常:

  • 如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常;
  • 如果虛擬機器在擴充套件棧時無法申請到足夠的記憶體空間,將丟擲OutOfMemoryError異常。

測試程式碼:

public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();

        try{
            oom.stackLeak();
        } catch(Throwable e) {
            System.out.println("stack length = " + oom.stackLength);
            throw e;
        }
    }
}

3.3 方法區和執行時常量池溢位

方法區配置引數:-XX:PermSize-XX:MaxPermSize

String.intern()是一個Native方法,它的作用是:如果字串常量池中已經包含一個等於此String物件的字串,則返回代表池中這個字串的String物件;否則,將此物件包含的字串新增到常量池中,並且返回此String物件的引用。

測試程式碼:

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        int i = 0;
        while (true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

3.4 本機直接記憶體溢位

直接記憶體配置引數:-XX:MaxDirectMemorySize

三、Java垃圾回收機制

1. 物件存活判斷及垃圾回收概述

GC需要完成三件事情:

  • 哪些記憶體需要回收?
  • 什麼時候回收?
  • 如何回收?

為什麼需要去了解GC和記憶體分配呢?答案很簡單:當需要排查各種記憶體溢位、記憶體洩露問題時,當垃圾收整合為系統達到更高併發量的瓶頸時,我們就需要對這些“自動化”的技術實施必要的監控和調節。

Java堆和方法區的記憶體的分配和回收是動態的,垃圾收集器主要關注的就是這部分記憶體。

物件存亡問題:在堆裡面存放著Java世界中幾乎所有的物件例項,垃圾收集器在對堆進行回收前,第一件事情就是要確定這些物件之中哪些還“存活”著,哪些已經“死去”(即不可能再被任何途徑使用的物件)。

1.1 引用計數演算法

引用計數演算法:給物件新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。

客觀的說,引用計數演算法(Reference Counting)實現簡單,判定效率也很高。但Java虛擬機器並沒有選用引用計數演算法來管理記憶體,最主要的原因是引用計數演算法很難解決物件間相互迴圈引用的問題

1.2 可達性分析演算法

在主流的商用程式語言中(Java、C#)的主流實現中,都是通過可達性分析(Reachability Analysis)來判定物件是否存活的

可達性分析演算法:通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用連結相連(用圖論的話來說,就是從GC Roots到這個物件不可達)時,則證明該物件是不可用的。

Java虛擬機器:記憶體管理與執行引擎

在Java語言中,可作為GC Roots的物件包括下面幾種:

  • 虛擬機器棧(棧幀中的本地變數表)中引用的物件;
  • 方法區中類靜態屬性引用的物件;
  • 方法區中常量引用的物件;
  • 本地方法棧中JNI(Native方法)引用的物件。

1.3 引用型別

無論是通過引用計算演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件的引用鏈是否可達,判定物件是否存活都與“引用”有關。

Java 對引用的概念進行了擴充,引入四種強度不同的引用型別。

強引用

只要強引用存在,垃圾回收器永遠不會回收調掉被引用的物件。

使用 new 一個新物件的方式來建立強引用。

Object object = new Object();

軟引用

用來描述一些還有用但是並非必需的物件。

在系統將要發生記憶體溢位異常之前,將會對這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲溢位異常。

軟引用主要用來實現類似快取的功能,在記憶體足夠的情況下直接通過軟引用取值,無需從繁忙的真實來源獲取資料,提升速度;當記憶體不足時,自動刪除這部分快取資料,從真正的來源獲取這些資料。

使用 SoftReference 類來實現軟引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);

弱引用

只能生存到下一次垃圾收集發生之前,當垃圾收集器工作時,無論當前記憶體是否足夠,都會被回收。

使用 WeakReference 類來實現弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);

虛引用

一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用取得一個物件例項。

為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。

使用 PhantomReference 來實現虛引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);

虛引用與軟引用和弱引用的一個區別在於:虛引用必須和引用佇列 (ReferenceQueue)聯合使用。當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。

關於Java中的軟引用、弱引用和虛引用,可以參見部落格:java中的弱引用、軟引用和虛引用

1.4 兩次標記清除

即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的。

要真正宣告一個物件死亡,至少要經歷兩次標記過程

  • 如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法

  • 如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。finalize()方法是物件逃脫死亡命運的最後一次機會。在finalize()函式中,如果物件重新與引用鏈上的任何一個物件建立關聯,那麼第二次標記是就將其移除出“即將回收”的集合;否則,第二次標記時,物件將會被宣告真正死亡

注意,任何一個物件的finalize()方法都只會被系統自動呼叫一次,不鼓勵使用該方法來拯救物件。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好。

1.5 方法區回收

在方法區中進行垃圾收集的價效比一般比較低:在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可以回收70%-95%的空間,而永久代的垃圾收集效率遠低於此。

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類

回收廢棄常量與回收堆中的物件非常類似,基於引用的方法可以實現。但是,判定一個類是否是“無用的類”的條件相對苛刻許多。

一個類需要同時滿足下面三個條件才能算是“無用的類”:

  • 該類的所有例項都已經被回收;
  • 載入該類的ClassLoader已經被回收;
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

在大量使用反射、動態代理、CGLib等ByteCode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

2. 垃圾回收演算法

2.1 標記-清除演算法(Mark-Sweep)

標記-清除演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件

Java虛擬機器:記憶體管理與執行引擎

不足之處:

  • 效率問題:標記和清除兩個過程的效率都不高;
  • 空間問題:標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

具體工作過程如下:

Java虛擬機器:記憶體管理與執行引擎

記憶體分佈圖:可見記憶體碎片化問題嚴重。

Java虛擬機器:記憶體管理與執行引擎

2.2 複製演算法(Copying)

複製演算法有效地解決了效率問題。

過程為:演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活的物件複製到另一塊上面,然後把已使用的記憶體空間一次清理掉

Java虛擬機器:記憶體管理與執行引擎

該演算法過程實現簡單,執行高效。但是,記憶體縮小一半,代價太大。

具體工作過程如下:

Java虛擬機器:記憶體管理與執行引擎

記憶體分佈圖:可見很好地解決了記憶體碎片化問題。

Java虛擬機器:記憶體管理與執行引擎

現在的商用虛擬機器都採用這種收集演算法來回收新生代,由於新生代中的物件98%都是朝生夕死,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor(兩塊Survivor輪流使用)。當回收時,將Eden和Survivor中還存活的物件一次性地複製到另外一個Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。

HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1。

當Survivor空間不夠用時,需要依賴其他記憶體(老年代)進行分配擔保(Handle Promotion)。對於分配擔保,如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活物件時,這些物件將直接通過分配擔保機制進入老年代

2.3 標記-整理演算法(Mark-Compact)

複製收集演算法在物件存活率較高時就要進行較多的賦值操作,效率將會變低。

Java虛擬機器:記憶體管理與執行引擎

對於標記-整理演算法,其過程與標記-清除演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體

具體工作過程如下:

Java虛擬機器:記憶體管理與執行引擎

記憶體分佈圖為:

Java虛擬機器:記憶體管理與執行引擎

2.4 分代收集演算法

當前商業虛擬機器的垃圾收集都採用“分代收集(Generational Collection)”演算法,其根據物件存活週期的不同將記憶體劃分為幾塊。一般是把Java堆分為新生代老年代

在新生代中,大多采用複製收集演算法。在老年代中,由於物件存活率較高並沒有額外空間進行分配擔保,多是使用“標記-清除”和“標記-整理”演算法來進行回收

3. 垃圾回收實現

3.1 列舉根節點

從可達性分析中從GC Roots節點找引用鏈這個操作為例,可作為GC Roots的節點主要在全域性性的引用與執行上下文中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這裡面的引用,那麼必然會消耗很多時間。

在準確式GC中,當執行系統停頓下來後,並不需要一個不漏地檢查完所有執行上下文和全域性的引用位置,虛擬機器應當有辦法直接得知哪些地方存放著物件引用。

在HotSpot中,其使用一組稱為OopMap的資料結構來達到這個目的,在類載入完成時,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來。這樣,GC在掃描時就可以得知這些資訊了。

3.2 安全點(SafePoint)

程式執行時,並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。

安全點的選定既不能太少以致於GC等待時間太長,也不能過於頻繁以致於過分增大執行時的負荷。因此,安全點的選定基本上是以程式是否具有讓程式長時間執行的特徵為標準進行選定的。

對於安全點,另一個問題就是如何在GC發生時讓所有執行緒都執行到最近的安全點上再停頓下來。兩種方案:

  • 搶先式中斷(Preemptive Suspension)
  • 主動式中斷(Voluntary Suspension)

對於搶先式中斷,其不需要執行緒的執行程式碼主動去配合,在GC發生時,首先把所有執行緒全部中斷,如果發現執行緒中斷的地方不在安全點上,就恢復執行緒,讓它執行到安全點上。

對於主動式中斷,其在當GC需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。

現在基本使用“安全點輪詢和觸發執行緒中斷”的主動式中斷機制。

4. 垃圾收集器

如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。

下圖是HotSpot虛擬機器的垃圾收集器。如果兩個收集器之間存在連線,就說明可以搭配使用。虛擬機器所處的區域,則表示它是屬於新生代收集器還是老年代收集器。

4.1 Serial收集器

它是單執行緒的收集器。

這不僅意味著只會使用一個執行緒進行垃圾收集工作,更重要的是它在進行垃圾收集時,必須暫停所有其他工作執行緒,往往造成過長的等待時間。

Java虛擬機器:記憶體管理與執行引擎

它的優點是簡單高效,對於單個 CPU 環境來說,由於沒有執行緒互動的開銷,因此擁有最高的單執行緒收集效率。

在 Client 應用場景中,分配給虛擬機器管理的記憶體一般來說不會很大,該收集器收集幾十兆甚至一兩百兆的新生代停頓時間可以控制在一百多毫秒以內,只要不是太頻繁,這點停頓是可以接受的。

4.2 ParNew收集器

它是 Serial 收集器的多執行緒版本。

Java虛擬機器:記憶體管理與執行引擎

它是 Server 模式下的虛擬機器首選新生代收集器,除了效能原因外,主要是因為除了 Serial 收集器,只有它能與 CMS 收集器配合工作。

預設開始的執行緒數量與 CPU 數量相同,可以使用 -XX:ParallelGCThreads 引數來設定執行緒數。

4.3 Paraller Scavenge收集器

它是並行的多執行緒收集器。

其它收集器關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間,而它的目標是達到一個可控制的吞吐量,它被稱為“吞吐量優先”收集器。這裡的吞吐量指 CPU 用於執行使用者程式碼的時間佔總時間的比值。

停頓時間越短就越適合需要與使用者互動的程式,良好的響應速度能提升使用者體驗。而高吞吐量則可以高效率地利用 CPU 時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

提供了兩個引數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數(值為大於 0 且小於 100 的整數)。縮短停頓時間是以犧牲吞吐量和新生代空間來換取的:新生代空間變小,垃圾回收變得頻繁,導致吞吐量下降。

還提供了一個引數 -XX:+UseAdaptiveSizePolicy,這是一個開關引數,開啟引數後,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 區的比例(-XX:SurvivorRatio)、晉升老年代物件年齡(-XX:PretenureSizeThreshold)等細節引數了,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最合適的停頓時間或者最大的吞吐量,這種方式稱為 GC 自適應的調節策略(GC Ergonomics)。自適應調節策略也是它與 ParNew 收集器的一個重要區別。

4.4 Serial Old收集器

Java虛擬機器:記憶體管理與執行引擎

Serial Old 是 Serial 收集器的老年代版本,也是給 Client 模式下的虛擬機器使用。如果用在 Server 模式下,它有兩大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 誕生以前)中與 Parallel Scavenge 收集器搭配使用。
  • 作為 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用。

4.5 Parallel Old收集器

它是 Parallel Scavenge 收集器的老年代版本。

Java虛擬機器:記憶體管理與執行引擎

在注重吞吐量以及 CPU 資源敏感的場合,都可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。

4.6 CMS收集器

CMS(Concurrent Mark Sweep),從 Mark Sweep 可以知道它是基於標記 - 清除演算法實現的。

Java虛擬機器:記憶體管理與執行引擎

特點:併發收集、低停頓

分為以下四個流程:

  1. 初始標記:僅僅只是標記一下 GC Roots 能直接關聯到的物件,速度很快,需要停頓。
  2. 併發標記:進行 GC Roots Tracing 的過程,它在整個回收過程中耗時最長,不需要停頓。
  3. 重新標記:為了修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,需要停頓。
  4. 併發清除:不需要停頓。

在整個過程中耗時最長的併發標記和併發清除過程中,收集器執行緒都可以與使用者執行緒一起工作,不需要進行停頓。

具有以下缺點:

  • 對 CPU 資源敏感。CMS 預設啟動的回收執行緒數是 (CPU 數量 + 3) / 4,當 CPU 不足 4 個時,CMS 對使用者程式的影響就可能變得很大,如果本來 CPU 負載就比較大,還要分出一半的運算能力去執行收集器執行緒,就可能導致使用者程式的執行速度忽然降低了 50%,其實也讓人無法接受。並且低停頓時間是以犧牲吞吐量為代價的,導致 CPU 利用率變低。
  • 無法處理浮動垃圾。由於併發清理階段使用者執行緒還在執行著,伴隨程式執行自然就還會有新的垃圾不斷產生。這一部分垃圾出現在標記過程之後,CMS 無法在當次收集中處理掉它們,只好留到下一次 GC 時再清理掉,這一部分垃圾就被稱為“浮動垃圾”。也是由於在垃圾收集階段使用者執行緒還需要執行,那也就還需要預留有足夠的記憶體空間給使用者執行緒使用,因此它不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分空間提供併發收集時的程式運作使用。可以使用 -XX:CMSInitiatingOccupancyFraction 的值來改變觸發收集器工作的記憶體佔用百分比,JDK 1.5 預設設定下該值為 68,也就是當老年代使用了 68% 的空間之後會觸發收集器工作。如果該值設定的太高,導致浮動垃圾無法儲存,那麼就會出現 Concurrent Mode Failure,此時虛擬機器將啟動後備預案:臨時啟用 Serial Old 收集器來重新進行老年代的垃圾收集。
  • 標記 - 清除演算法導致的空間碎片,給大物件分配帶來很大麻煩,往往出現老年代空間剩餘,但無法找到足夠大連續空間來分配當前物件,不得不提前觸發一次 Full GC。

4.7 G1收集器

G1(Garbage-First)收集器是當今收集器技術發展最前沿的成果之一,它是一款面向服務端應用的垃圾收集器,HotSpot 開發團隊賦予它的使命是(在比較長期的)未來可以替換掉 JDK 1.5 中釋出的 CMS 收集器。

Java虛擬機器:記憶體管理與執行引擎

具備如下特點:

  • 並行與併發:能充分利用多 CPU 環境下的硬體優勢,使用多個 CPU 來縮短停頓時間。
  • 分代收集:分代概念依然得以保留,雖然它不需要其它收集器配合就能獨立管理整個 GC 堆,但它能夠採用不同方式去處理新建立的物件和已存活一段時間、熬過多次 GC 的舊物件來獲取更好的收集效果。
  • 空間整合:整體來看是基於“標記 - 整理”演算法實現的收集器,從區域性(兩個 Region 之間)上來看是基於“複製”演算法實現的,這意味著執行期間不會產生記憶體空間碎片。
  • 可預測的停頓:這是它相對 CMS 的一大優勢,降低停頓時間是 G1 和 CMS 共同的關注點,但 G1 除了降低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為 M 毫秒的時間片段內,消耗在 GC 上的時間不得超過 N 毫秒,這幾乎已經是實時 Java(RTSJ)的垃圾收集器的特徵了。

在 G1 之前的其他收集器進行收集的範圍都是整個新生代或者老生代,而 G1 不再是這樣,Java 堆的記憶體佈局與其他收集器有很大區別,將整個 Java 堆劃分為多個大小相等的獨立區域(Region)。雖然還保留新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,而都是一部分 Region(不需要連續)的集合。

之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在整個 Java 堆中進行全區域的垃圾收集。它跟蹤各個 Region 裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region(這也就是 Garbage-First 名稱的來由)。這種使用 Region 劃分記憶體空間以及有優先順序的區域回收方式,保證了它在有限的時間內可以獲取儘可能高的收集效率。

Region 不可能是孤立的,一個物件分配在某個 Region 中,可以與整個 Java 堆任意的物件發生引用關係。在做可達性分析確定物件是否存活的時候,需要掃描整個 Java 堆才能保證準確性,這顯然是對 GC 效率的極大傷害。為了避免全堆掃描的發生,每個 Region 都維護了一個與之對應的 Remembered Set。虛擬機器發現程式在對 Reference 型別的資料進行寫操作時,會產生一個 Write Barrier 暫時中斷寫操作,檢查 Reference 引用的物件是否處於不同的 Region 之中,如果是,便通過 CardTable 把相關引用資訊記錄到被引用物件所屬的 Region 的 Remembered Set 之中。當進行記憶體回收時,在 GC 根節點的列舉範圍中加入 Remembered Set 即可保證不對全堆掃描也不會有遺漏。

如果不計算維護 Remembered Set 的操作,G1 收集器的運作大致可劃分為以下幾個步驟:

  1. 初始標記
  2. 併發標記
  3. 最終標記:為了修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄線上程的 Remembered Set Logs 裡面,最終標記階段需要把 Remembered Set Logs 的資料合併到 Remembered Set 中。這階段需要停頓執行緒,但是可並行執行。
  4. 篩選回收:首先對各個 Region 中的回收價值和成本進行排序,根據使用者所期望的 GC 停頓是時間來制定回收計劃。此階段其實也可以做到與使用者程式一起併發執行,但是因為只回收一部分 Region,時間是使用者可控制的,而且停頓使用者執行緒將大幅度提高收集效率。

4.8 七種垃圾收集器的對比

收集器 序列、並行 or 併發 新生代 / 老年代 演算法 目標 適用場景
Serial 序列 新生代 複製演算法 響應速度優先 單 CPU 環境下的 Client 模式
Serial Old 序列 老年代 標記-整理 響應速度優先 單 CPU 環境下的 Client 模式、CMS 的後備預案
ParNew 並行 新生代 複製演算法 響應速度優先 多 CPU 環境時在 Server 模式下與 CMS 配合
Parallel Scavenge 並行 新生代 複製演算法 吞吐量優先 在後臺運算而不需要太多互動的任務
Parallel Old 並行 老年代 標記-整理 吞吐量優先 在後臺運算而不需要太多互動的任務
CMS 併發 老年代 標記-清除 響應速度優先 集中在網際網路站或 B/S 系統服務端上的 Java 應用
G1 併發 both 標記-整理 + 複製演算法 響應速度優先 面向服務端應用,將來替換 CMS

四、記憶體分配與回收策略

物件的記憶體分配,也就是在堆上分配。主要分配在新生代的 Eden 區上,少數情況下也可能直接分配在老年代中。

1. 優先在 Eden 分配

大多數情況下,物件在新生代 Eden 區分配,當 Eden 區空間不夠時,發起 Minor GC。

關於 Minor GC 和 Full GC:

  • Minor GC:發生在新生代上,因為新生代物件存活時間很短,因此 Minor GC 會頻繁執行,執行的速度一般也會比較快。
  • Full GC:發生在老年代上,老年代物件和新生代的相反,其存活時間長,因此 Full GC 很少執行,而且執行速度會比 Minor GC 慢很多。

2. 大物件直接進入老年代

大物件是指需要連續記憶體空間的物件,最典型的大物件是那種很長的字串以及陣列。經常出現大物件會提前觸發垃圾收集以獲取足夠的連續空間分配給大物件。

提供 -XX:PretenureSizeThreshold 引數,大於此值的物件直接在老年代分配,避免在 Eden 區和 Survivor 區之間的大量記憶體複製。

3. 長期存活的物件進入老年代

JVM 為物件定義年齡計數器,經過 Minor GC 依然存活,並且能被 Survivor 區容納的,移被移到 Survivor 區,年齡就增加 1 歲,增加到一定年齡則移動到老年代中(預設 15 歲,通過 -XX:MaxTenuringThreshold 設定)。

4. 動態物件年齡判定

JVM 並不是永遠地要求物件的年齡必須達到 MaxTenuringThreshold 才能晉升老年代,如果在 Survivor 區中相同年齡所有物件大小的總和大於 Survivor 空間的一半,則年齡大於或等於該年齡的物件可以直接進入老年代,無序等待 MaxTenuringThreshold 中要求的年齡。

5. 空間分配擔保

在發生 Minor GC 之前,JVM 先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果條件成立的話,那麼 Minor GC 可以確認是安全的;如果不成立的話 JVM 會檢視 HandlePromotionFailure 設定值是否允許擔保失敗,如果允許那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次 Minor GC,儘管這次 Minor GC 是有風險的;如果小於,或者 HandlePromotionFailure 設定不允許冒險,那這時也要改為進行一次 Full GC。

6. Full GC的觸發條件

對於 Minor GC,其觸發條件非常簡單,當 Eden 區空間滿時,就將觸發一次 Minor GC。而 Full GC 則相對複雜,有以下條件:

6.1 呼叫 System.gc()

此方法的呼叫是建議 JVM 進行 Full GC,雖然只是建議而非一定,但很多情況下它會觸發 Full GC,從而增加 Full GC 的頻率,也即增加了間歇性停頓的次數。因此強烈建議能不使用此方法就不要使用,讓虛擬機器自己去管理它的記憶體。可通過 -XX:+ DisableExplicitGC 來禁止 RMI 呼叫 System.gc()。

6.2 老年代空間不足

老年代空間不足的常見場景為前文所講的大物件直接進入老年代、長期存活的物件進入老年代等,當執行 Full GC 後空間仍然不足,則丟擲 Java.lang.OutOfMemoryError。為避免以上原因引起的 Full GC,調優時應儘量做到讓物件在 Minor GC 階段被回收、讓物件在新生代多存活一段時間以及不要建立過大的物件及陣列。

6.3 空間分配擔保失敗

使用複製演算法的 Minor GC 需要老年代的記憶體空間作擔保,如果出現了 HandlePromotionFailure 擔保失敗,則會觸發 Full GC。

6.4 JDK 1.7 及以前的永久代空間不足

在 JDK 1.7 及以前,HotSpot 虛擬機器中的方法區是用永久代實現的,永久代中存放的為一些 class 的資訊、常量、靜態變數等資料,當系統中要載入的類、反射的類和呼叫的方法較多時,永久代可能會被佔滿,在未配置為採用 CMS GC 的情況下也會執行 Full GC。如果經過 Full GC 仍然回收不了,那麼 JVM 會丟擲 java.lang.OutOfMemoryError,為避免以上原因引起的 Full GC,可採用的方法為增大永久代空間或轉為使用 CMS GC。

6.5 Concurrent Mode Failure

執行 CMS GC 的過程中同時有物件要放入老年代,而此時老年代空間不足(有時候“空間不足”是 CMS GC 時當前的浮動垃圾過多導致暫時性的空間不足觸發 Full GC),便會報 Concurrent Mode Failure 錯誤,並觸發 Full GC。

五、JVM監控工具

給一個系統定位問題的時候,知識、經驗是關鍵基礎,資料是依據,工具是運用知識處理資料的手段。

這些資料包括:

  • 執行日誌
  • 異常堆疊
  • GC日誌
  • 執行緒快照(threaddump/javacore)
  • 堆轉儲快照(heapdump/hprof)

1. jps:虛擬機器程式狀況工具

jps(JVM Process Status Tool)主要用於顯示指定系統內所有的HotSpot虛擬機器程式。

2. jstat:虛擬機器統計資訊監視工具

jstat(JVM Statistics Monitoring Tool)是用於監視虛擬機器各種執行狀態資訊的命令列工具。它可以顯示本地或者遠端虛擬機器程式中類裝載、記憶體、垃圾收集等執行資料。

3. jinfo:Java配置資訊工具

jinfo(Configuration Info for Java)的作用是實時地檢視和調整虛擬機器各項引數。

4. jmap:Java記憶體映像工具

jmap(Memory Map for Java)命令用於生成堆轉儲快照。jmap的作用不僅僅是為了獲取dump檔案,還可以查詢finalize執行佇列、Java堆和永久代的詳細資訊,如空間使用率、當前用的是哪種收集器等。

5. jhat:虛擬機器堆轉儲快照分析工具

jhat(JVM Heap Analysis Tool)命令主要用於分析jmap生成的堆轉儲快照。

6. jstack:Java堆疊跟蹤工具

jstack(Stack Trace for Java)命令用於生成虛擬機器當前時刻的執行緒快照(一般稱為threaddump或者javacore檔案)。

7. JConsole:Java監視與管理控制檯

JConsole(Java Monitoring and Management Console)是一種基於JMX的視覺化監視管理工具。

8.VisualVM:多合一故障處理工具

VisualVM 是一款免費的,整合了多個 JDK 命令列工具的視覺化工具,它能提供強大的分析能力,對 Java 應用程式做效能分析和調優。這些功能包括生成和分析海量資料、跟蹤記憶體洩漏、監控垃圾回收器、執行記憶體和 CPU 分析,同時它還支援在 MBeans 上進行瀏覽和操作。

六、Class類檔案解析

Java:一次編寫,到處執行。Wirte Once, Run Anywhere。

各種不同平臺的虛擬機器與所有平臺都統一使用的程式儲存格式——位元組碼(ByteCode)是構成平臺無關性的基石。

Java虛擬機器不和包括Java在內的任何語言繫結,它只與“Class檔案”這種特定的二進位制檔案格式所關聯。

Class檔案中包含了Java虛擬機器指令集和符號表以及若干其他輔助資訊。

1. Class類檔案的結構

任何一個Class檔案都對應著唯一一個類或介面的定義資訊,但反過來說,類或介面並不一定都得定義在檔案裡(譬如類或介面也可以通過類載入器直接生成)。

Class檔案是一組以8位位元組為基礎單元的二進位制流,各個資料專案嚴格按照順序緊湊地排列在Class檔案之中,中間沒有新增任何分隔符。

Class檔案中只有兩種資料型別:無符號數和表

無符號數屬於基本的資料型別,可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字串值。

表是有多個無符號數或者其他表作為資料項構成的複合資料型別,所有表都習慣性地以“_info”結尾。

Class檔案中的資料項,無論是順序還是數量,甚至於資料儲存的位元組序,都是被嚴格限定的,哪個位元組代表什麼含義,長度是多少,先後順序如何,都不允許改變。

Class檔案格式如下:

Java虛擬機器:記憶體管理與執行引擎

下面,我們就Class檔案中各個資料項的具體含義進行分析。

  • 魔數(magic)

每個Class檔案的頭4個位元組稱為魔數(magic),它的唯一作用是判斷該檔案是否為一個能被虛擬機器接受的Class檔案。它的值固定為0xCAFEBABE。

  • Class檔案版本(version)

緊接著magic的4個位元組儲存的是Class檔案的次版本號和主版本號,高版本的JDK能向下相容低版本的Class檔案,但不能執行更高版本的Class檔案。

  • 常量池(constant_pool)

緊接著主次版本號之後的是常量池入口,常量池可以理解為Class檔案之中的資源倉庫,它是Class檔案結構中與其他專案關聯最多的資料型別,也是佔用Class檔案空間最大的資料專案之一,同時它還是在Class檔案中第一個出現的表型別資料專案。

常量池中主要存放兩大類常量:字面量和符號引用

字面量比較接近於Java層面的常量概念,如文字字串、被宣告為final的常量值等。

符號引用總結起來則包括了下面三類常量:

  • 類和介面的全限定名(即帶有包名的Class名,如:org.lxh.test.TestClass)
  • 欄位的名稱和描述符(private、static等描述符)
  • 方法的名稱和描述符(private、static等描述符)

虛擬機器在載入Class檔案時才會進行動態連線,也就是說,Class檔案中不會儲存各個方法和欄位的最終記憶體佈局資訊,因此,這些欄位和方法的符號引用不經過轉換是無法直接被虛擬機器使用的。當虛擬機器執行時,需要從常量池中獲得對應的符號引用,再在類載入過程中的解析階段將其替換為直接引用,並翻譯到具體的記憶體地址中。

這裡說明下符號引用和直接引用的區別與關聯:

符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到了記憶體中。

直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是與虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那說明引用的目標必定已經存在於記憶體之中了。

  • 訪問標誌(access_flags)

在常量池結束之後,緊接著的兩個位元組表示訪問標誌(access_flags),這個標誌用於識別一些類或者介面層次的訪問資訊。包括:

  • 這個Class是類還是介面;
  • 是否定義為public型別;
  • 是否定義為abstract型別;
  • 如果是類的話,是否被宣告為final等。

訪問標誌包括public/protected/private/abstract/final等等。

  • 類索引、父類索引與介面索引集合

類索引(this_class):用於確定這個類的全限定名(u2型別)。

父類索引(super_class):用於確定這個類的父類的全限定名(u2型別)(Java不允許多重繼承!!!)。

介面索引集合(interfaces):用於描述這個類實現了哪些介面,這些被實現的介面將按implements語句後的介面順序從左到右在介面索引集合中(u2型別資料集合)。

  • 欄位表集合(field_info)

欄位表(field_info)用於描述介面或者類中宣告的變數。

欄位(field)包括類級變數以及例項級變數,但不包括在方法內部宣告的區域性變數。

可以包括的資訊包括:

  • 欄位的作用域(public/private/protected修飾符)
  • 是例項變數還是類變數(static修飾符)
  • 可變性(final修飾符)
  • 併發可見性(volatile修飾符)
  • 可否被序列化(transient修飾符)
  • 欄位資料型別(基本型別、物件、陣列)
  • 欄位名稱等。

欄位表格式如下:

Java虛擬機器:記憶體管理與執行引擎

其中的access_flags與類中的access_flags類似,是表示資料型別的修飾符,如public、static、volatile等。

後面的name_index和descriptor_index都是對常量池的引用,分別代表欄位的簡單名稱及欄位和方法的描述符。

注意:欄位表集合中不會列出從超類或父介面中繼承而來的欄位,但有可能列出原本Java程式碼中不存在的欄位,譬如在內部類中為了保持對外部類的訪問性,會自動新增指向外部類例項的欄位。

  • 方法表集合(method_info)

方法表(method_info)的結構與欄位表的結構相同,如下表所示。

Java虛擬機器:記憶體管理與執行引擎

方法裡的Java程式碼,經過編譯器編譯成位元組碼指令後,存放在方法屬性表集合中一個名為“Code”的屬性裡。

與欄位表集合相對應,如果父類方法在子類中沒有被覆寫,方法表集合中就不會出現來自父類的方法資訊。但同樣,有可能會出現由編譯器自動新增的方法,最典型的便是類構造器<clinit>方法和例項構造器<init>方法。

在Java語言中,要過載一個方法,除了要與原方法具有相同的簡單名稱外,還要求必須擁有一個與原方法不同的特徵簽名,特徵簽名就是一個方法中各個引數在常量池中的欄位符號引用的集合,也就是因為返回值不會包含在特徵簽名之中,因此Java語言裡無法僅僅依靠返回值的不同來對一個已有方法進行過載。

  • 屬性表集合(attribute_info)

在前面的Class檔案、欄位表、方法表都可以攜帶自己的屬性表集合,用於描述某些場景專有的資訊。

Java虛擬機器:記憶體管理與執行引擎

Code屬性:

Java程式方法體中的程式碼講過Javac編譯後,生成的位元組碼指令便會儲存在Code屬性中,但並非所有的方法表都必須存在這個屬性,比如介面或抽象類中的方法就不存在Code屬性。

Code屬性是Class檔案中最重要的一個屬性,如果把一個Java程式中的資訊分為程式碼和後設資料兩部分,那麼在整個Class檔案裡,Code屬性用於描述程式碼,所有的其他資料專案都用於描述後設資料。

Exception屬性:

這裡的Exception屬性的作用是列舉出方法中可能丟擲的受查異常,也就是方法描述時在throws關鍵字後面列舉的異常。它的結構很簡單,只有attribute_name_index、attribute_length、number_of_exceptions、exception_index_table四項,從字面上便很容易理解,這裡不再詳述。

LineNumberTable屬性:

它用於描述Java原始碼行號與位元組碼行號之間的對應關係。

LocalVariableTable屬性:

它用於描述棧幀中區域性變數表中的變數與Java原始碼中定義的變數之間的對應關係。

SourceFile屬性:

它用於記錄生成這個Class檔案的原始碼檔名稱。

ConstantValue屬性:

ConstantValue屬性的作用是通知虛擬機器自動為靜態變數賦值,只有被static修飾的變數才可以使用這項屬性。

在Java中,對非static型別的變數(也就是例項變數)的賦值是在例項構造器<init>方法中進行的;而對於類變數(static變數),則有兩種方式可以選擇:

  • 在類構造其中賦值
  • 使用ConstantValue屬性賦值

下面簡要說明下final、static、static final修飾的欄位賦值的區別:

  • static修飾的欄位在類載入過程中的準備階段被初始化為0或null等預設值,而後在初始化階段(觸發類構造器)才會被賦予程式碼中設定的值,如果沒有設定值,那麼它的值就為預設值。
  • final修飾的欄位在執行時被初始化(可以直接賦值,也可以在例項構造器中賦值),一旦賦值便不可更改;
  • static final修飾的欄位在Javac時生成ConstantValue屬性,在類載入的準備階段根據ConstantValue的值為該欄位賦值,它沒有預設值,必須顯式地賦值,否則Javac時會報錯。可以理解為在編譯期即把結果放入了常量池中。

InnerClasses屬性:

該屬性用於記錄內部類與宿主類之間的關聯。如果一個類中定義了內部類,那麼編譯器將會為它及它所包含的內部類生成InnerClasses屬性。

Deprecated屬性和Synthetic屬性:

該屬性用於表示某個類、欄位和方法,已經被程式作者定為不再推薦使用,它可以通過在程式碼中使用@Deprecated註釋進行設定。

Synthetic屬性:

該屬性代表此欄位或方法並不是Java原始碼直接生成的,而是由編譯器自行新增的,如this欄位和例項構造器、類構造器等。

2. 位元組碼指令簡介

Java虛擬機器的指令由一個位元組長度的、代表某種特定操作含義的數字(稱為操作碼,opcode)以及跟隨其後的零至多個代表此操作所需引數(稱為運算元,oprands)而構成。

2.1 載入和儲存指令

載入和儲存指令用於將資料在棧幀中的區域性變數表和運算元棧之間來回傳輸。

包括:*load/*store/*push/wide/......

2.2 運算指令

運算或算術指令用於對兩個運算元棧上的值進行某種特定運算,並把結果重新存入到運算元棧頂。

包括:*add/*sub/*mul/*div/*rem/*neg/*sh*/*or/*and/*xor/*inc/*cmp*/...

2.3 型別轉換指令

型別轉換指令可以將兩種不同的數值型別進行相互轉換,這些轉換操作一般用於實現使用者程式碼中的顯式型別轉換操作。

Java虛擬機器直接支援以下數值型別的寬化型別轉換(Widening Numeric Conversions,即小範圍型別向大範圍型別的安全轉換):

  • int型別到long/float/double型別;
  • long型別到float/double型別;
  • float型別到double型別。

相對的,處理窄化型別轉換(Narrowing Numeric Conversions)時,必須顯式地使用轉換指令來完成。

包括:i2b/i2c/i2s/l2i/f2i/f2l/d2i/d2l/d2f/...

2.4 物件建立與訪問指令

物件建立後,就可以通過物件訪問指令獲取物件例項或者陣列例項中的欄位或者陣列元素。

包括:

  • 建立類例項的指令:new
  • 建立陣列的指令:newarray/anewarray/multianewarray
  • 訪問類欄位(static欄位)和例項欄位(非static欄位):getfield/putfield/getstatic/putstatic
  • 把一個陣列元素載入到運算元棧的指令:baload/caload/saload/...
  • 將一個運算元棧的值儲存到陣列元素中的指令:bastore/castore/sastore/...
  • 取陣列長度的指令:arraylength
  • 檢查類例項型別的指令:instanceof/checkcast

2.5 運算元棧管理指令

Java虛擬機器提供了一些用於直接操作運算元棧的指令。

包括:pop/pop2/dup*/swap/...

2.6 控制轉移指令

控制轉移指令可以讓Java虛擬機器有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程式。

包括:

  • 條件分支:ifeq/iflt/...
  • 複合條件分支:tableswitch/lookupswitch
  • 無條件分支:goto/goto_w/jsr/jsr_w/ret

2.7 方法呼叫及返回指令

包括:

  • invokevirtual指令用於呼叫物件的例項方法,根據物件的實際型別進行分派
  • invokeinterface指令用於呼叫介面方法,它會在執行時搜尋一個實現了這個介面方法的物件,找出適合的方法進行呼叫
  • invokespecial指令用於呼叫一些需要特殊處理的例項方法,包括例項初始化方法、私有方法和父類方法
  • invokestatic指令用於呼叫類方法(static方法)
  • invokedynamic指令用於在執行時動態解析出呼叫點限定符所引用的方法,並執行該方法

前面四條呼叫指令的分派邏輯都固化在Java虛擬機器內部,而invokedynamic指令的分派邏輯是由使用者所設定的引導方法決定的。

2.8 異常處理指令

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

2.9 同步指令

同步一段指令集序列通常是由Java語言中的synchronized語句塊來表示的,Java虛擬機器的指令集中有monitorentermonitorexit兩條指令來支援synchronized關鍵字的語義。

四、虛擬機器類載入機制

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

1. 類載入過程

在Java語言裡面,型別的載入、連線、初始化過程都是在程式執行期間完成的。

特點:靈活性、動態擴充套件(執行期動態載入和動態連線)

Java虛擬機器:記憶體管理與執行引擎

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,整個生命週期包括:

  • 載入(Loading)
  • 驗證(Verification)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 解除安裝(Unloading)

那麼,什麼情況下需要開始類載入過程的第一個階段載入呢?!!有且只有五種情況!!

  • 遇到new/getstatic/putstatic/invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化(分別對應於:使用new例項化物件、讀取或設定類的靜態欄位、呼叫一個類的靜態方法)。
  • 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  • 當使用動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化,則需要先觸發其初始化。

被動引用:

  • 通過子類引用父類的靜態欄位,不會導致子類初始化;
  • 通過陣列定義來引用類,不會觸發此類的初始化;
  • 常量在編譯階段會調入類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。(常量傳播優化)

對於介面的載入過程,我們需要注意的是:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面時才會初始化。

類載入過程主要包括載入、驗證、準備、解析和初始化5個階段。

1.1 載入

在載入階段,虛擬機器需要完成以下三件事情:

  1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流;
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

值得注意的是,虛擬機器設計團隊在載入階段搭建了一個相當開放的、廣闊的“舞臺”。Java發展歷程中,開發人員在這個舞臺上玩出了各種花樣,例如:

  • 從zip包中讀取,最終成為jar/war格式的基礎。
  • 從網路中獲取,最典型應用就是applet。
  • 執行時計算生成,這種場景使用得最多得就是動態代理技術,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass的代理類的二進位制位元組流。
  • 由其他檔案生成,典型場景是 JSP 應用,即由 JSP 檔案生成對應的 Class 類。
  • 從資料庫讀取,這種場景相對少見,例如有些中介軟體伺服器(如 SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式程式碼在叢集間的分發。

非陣列類的載入

對於非陣列類的載入,既可以使用系統提供的引導類載入器來完成,也可以由使用者自定義的類載入器去完成。開發人員可以通過定義自己的類載入器去控制位元組流的獲取方式。

陣列類的載入

陣列類本身不通過類載入器去建立,而是由Java虛擬機器直接建立。一個陣列類建立過程遵循以下規則:

  • 如果陣列的元件型別是引用型別,採用載入過程去載入這個元件型別。

  • 如果陣列的元件型別不是引用型別,Java虛擬機器將會把陣列類標記為與引導類載入器關聯。

  • 陣列類的可見性與它的元件型別的可見性一致。

1.2 驗證

驗證階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害到虛擬機器自身的安全。驗證是虛擬機器對自身保護的一項重要工作。

從整體上看,驗證階段大致上會完成下面4個階段的檢驗動作:

  • 檔案格式驗證

第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。該驗證階段的主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內,格式上符合一個Java型別資訊的要求

  • 後設資料驗證

第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。該驗證階段的主要目的是對類的後設資料資訊進行語義校驗,保證不存在不符合Java語言規範的後設資料資訊

  • 位元組碼驗證

第三階段是對類的方法體進行校驗分析,保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件。該驗證階段的主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的

  • 符號引用驗證

第四階段是對類自身以外的資訊進行匹配性校驗。該驗證階段的主要目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會丟擲一個java.lang.IncompatibleClassChangeError異常的子類

對於虛擬機器的類載入機制來說,驗證階段是一個非常重要的、但不是一定必要的階段。

1.3 準備

準備階段是為類變數分配記憶體並設定類變數初始值的階段。

注意:此時進行記憶體分配的僅包括類變數(static修飾的變數),而不包括例項變數。

考慮下面一個問題:

試比較下面兩種情況下在準備階段後value對應的值是多少。

// 情形一
public static int value = 123;

// 情形二
public static final int value = 123;

答案是:對於情形一,準備階段後value的值為0;對於情形二,準備階段後value的值為123。

原因在於,情形一下value的賦值操作是在<init>部分完成的,而在情形二下,value對應為ConstantValue屬性。

1.4 解析

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

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
直接引用(Direct References):直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。

虛擬機器規範中並未規定解析階段發生的具體時間。

解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。

類或介面的解析

假設當前程式碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的直接引用,虛擬機器完成整個解析的過程需要以下三個步驟:

  • 如果C不是一個陣列型別,那虛擬機器會把代表N的全限定名傳遞給D的類載入器去載入這個類C。
  • 如果C是一個陣列型別,並且陣列的元素型別為物件,也就是N的描述符會是類似“[Ljava/lang/Integer”的形式,那將會按照第一點的規則載入陣列元素型別。
  • 如果上面的步驟沒有出現任何異常,那麼C在虛擬機器中實際上已經成為一個有效的類或介面了,但在解析完成之前還要進行符號引用驗證,確認D是否有訪問C的許可權。

欄位解析

要解析一個未被解析過的欄位符號引用,首先會對欄位表內class_index項中索引的CONSTANT_Class_info符號引用進行解析。虛擬機器規範要求按照以下步驟進行搜尋:

  • 如果C本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束;
  • 否則,如果在C中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束;
  • 否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束;
  • 否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。

類方法解析

對於類方法解析,其首先需要先解析出類方法表class_index項中索引的方法所屬的類或介面的符號引用,如果解析成功,接下來虛擬機器將會按照如下步驟進行後續的類方法搜尋:

  • 類方法和介面方法符號引用的常量型別定義是分開的,如果在類方法表中發現class_index中索引的C是個介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常;
  • 如果通過了第一步,在類C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  • 否則,在類C的父類中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接飲用,查詢結束;
  • 否則,在類C實現的介面列表以及它們的父介面之中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,則說明類C是一個抽象類,這是查詢結束,丟擲java.lang.AbstractMethodError異常;
  • 否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError。

介面方法解析

對於介面方法解析,也需要先解析出介面方法表的class_index項中索引的方法所屬的類或介面的符號引用,解析成功後,接下來虛擬機器將會按照如下步驟進行後續的介面方法搜尋:

  • 與類方法不同,如果在介面方法表中發現class_index中的索引C是個類而不是介面,則直接丟擲java.lang.IncompatibleClassChangeError異常;
  • 否則,在介面C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  • 否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  • 否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError異常。

1.5 初始化

類初始化階段是類載入的最後一步。在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源。也就是說,初始化階段是執行類構造器<clinit>方法的過程。

  • <clinit>方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。例如:
public class Test {
    static {
        i = 0;    // 給變數賦值可以正常編譯通過
        System.out.println(i); // 非法前向引用!!!
    }

    static int i = 1;
}
  • <clinit>方法與類的建構函式(例項構造器<init>)不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證在子類的<clinit>方法執行之前,父類的<clinit>方法已經執行完畢
  • 由於父類的<clinit>方法先執行,也就有,父類中定義的靜態語句塊要優先於子類的變數賦值操作
  • <clinit>方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>方法。
  • 介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作。但是介面與類不同的是,執行介面的<clinit>方法不需要先執行父介面的<clinit>方法,只有當父介面中定義的變數使用時,父介面才會初始化
  • 虛擬機器會保證一個類的<clinit>方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>方法完畢。同時,需要注意的是,其他執行緒雖然會被阻塞,但如果執行<clinit>方法的那條執行緒退出<clinit>方法後,其他執行緒喚醒之後不會再次進入<clinit>方法。同一個類載入器下,一個型別只會初始化一次。

2. 類載入器

虛擬機器設計團隊把類載入階段中的“通過一個類的全限定名來獲取描述此類的二進位制位元組流 ( 即位元組碼 )”這個動作放到 Java 虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為“類載入器”。

2.1 類與類載入器

對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在 Java 虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。通俗而言:比較兩個類是否“相等”(這裡所指的“相等”,包括類的 Class 物件的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括使用 instanceof() 關鍵字做物件所屬關係判定等情況),只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個 Class 檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那這兩個類就必定不相等。

2.2 類載入器分類

從 Java 虛擬機器的角度來講,只存在以下兩種不同的類載入器:

  • 啟動類載入器(Bootstrap ClassLoader),這個類載入器用 C++ 實現,是虛擬機器自身的一部分;
  • 所有其他類的載入器,這些類由 Java 實現,獨立於虛擬機器外部,並且全都繼承自抽象類 java.lang.ClassLoader。

從 Java 開發人員的角度看,類載入器可以劃分得更細緻一些:

  • 啟動類載入器(Bootstrap ClassLoader) 此類載入器負責將存放在 <JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被載入)類庫載入到虛擬機器記憶體中。 啟動類載入器無法被 Java 程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給啟動類載入器,直接使用 null 代替即可。
  • 擴充套件類載入器(Extension ClassLoader) 這個類載入器是由 ExtClassLoader實現的。它負責將<JAVA_HOME>/lib/ext或者被 java.ext.dir系統變數所指定路徑中的所有類庫載入到記憶體中,開發者可以直接使用擴充套件類載入器。
  • 應用程式類載入器(Application ClassLoader) 這個類載入器是由AppClassLoader實現的。由於這個類載入器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

2.3 雙親委派模型

應用程式都是由三種類載入器相互配合進行載入的,如果有必要,還可以加入自己定義的類載入器。下圖展示的類載入器之間的層次關係,稱為類載入器的雙親委派模型(Parents Delegation Model)。該模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器,這裡類載入器之間的父子關係一般通過組合(Composition)關係來實現,而不是通過繼承(Inheritance)的關係實現。

Java虛擬機器:記憶體管理與執行引擎

工作過程

如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入,而是把這個請求委派給父類載入器,每一個層次的載入器都是如此,依次遞迴。因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成此載入請求(它搜尋範圍中沒有找到所需類)時,子載入器才會嘗試自己載入。

好處

使用雙親委派模型來組織類載入器之間的關係,使得 Java 類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類 java.lang.Object,它存放在 rt.jar 中,無論哪個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此 Object 類在程式的各種類載入器環境中都是同一個類。相反,如果沒有雙親委派模型,由各個類載入器自行載入的話,如果使用者編寫了一個稱為java.lang.Object 的類,並放在程式的 ClassPath 中,那系統中將會出現多個不同的 Object 類,程式將變得一片混亂。如果開發者嘗試編寫一個與 rt.jar 類庫中已有類重名的 Java 類,將會發現可以正常編譯,但是永遠無法被載入執行。

實現

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
    //check the class has been loaded or not
    Class c = findLoadedClass(name);
    if(c == null) {
        try{
            if(parent != null) {
                c = parent.loadClass(name, false);
            } else{
                c = findBootstrapClassOrNull(name);
            }
        } catch(ClassNotFoundException e) {
            //if throws the exception , the father can not complete the load
        }
        if(c == null) {
            c = findClass(name);
        }
    }
    if(resolve) {
        resolveClass(c);
    }
    return c;
}

五、參考資料

相關文章