1. 程式人生 > >Java修煉之道--虛擬機器

Java修煉之道--虛擬機器

前言

在本文將深入討論 Java 虛擬機器相關核心知識

參考書籍:

  • 《深入理解 Java 虛擬機器》周志明,機械工業出版社

學習課程:

  • 【煉數成金】深入 JVM 核心—原理、診斷與優化
  • 【龍果學院】深入理解 Java 虛擬機器( JVM 效能調優+記憶體模型+虛擬機器原理)
  • 【尚學堂】白鶴翔 JVM 虛擬機器優化

核心知識

  • JVM 基本結構
    • 類載入器
    • 執行引擎
    • 執行時資料區
    • 本地介面

Class Files -> ClassLoader -> 執行時資料區 -> 執行引擎,本地庫介面 -> 本地方法庫

JVM體系結構

虛擬機器是物理機器的軟體實現。Java 的開發遵循 write once run anywhere(一次編寫到處亂跑)理念,它執行在VM(虛擬機器)上。編譯器將 Java 檔案編譯成 Java.class 檔案,之後,將 .class 檔案輸入到 JVM 中,載入並執行該類檔案。下圖是JVM 的體系結構

img

JVM各個模組簡介

  1. 執行時資料區:經過編譯生成的位元組碼檔案(class檔案),由 class loader(類載入子系統)載入後交給執行引擎執行。在執行引擎執行的過程中產生的資料會儲存在一塊記憶體區域。這塊記憶體區域就是執行時區域

  2. 程式計數器:用於記錄當前執行緒的正在執行的位元組碼指令位置。由於虛擬機器的多執行緒是切換執行緒並分配 cpu 執行時間的方式實現的,不同執行緒的執行位置都需要記錄下來,因此程式計數器是執行緒私有的

  3. 虛擬機器棧:虛擬機器棧是 Java 方法執行的記憶體結構,虛擬機器會在每個 Java 方法執行時建立一個“棧楨”,用於儲存區域性變量表,運算元棧,動態連結,方法出口等資訊。當方法執行完畢時,該棧楨會從虛擬機器棧中出棧。其中區域性變量表包含基本資料型別和物件引用

    • 在 Java 虛擬機器規範中,對這個區域規定了兩種異常狀態:如果執行緒請求的棧的深度大於虛擬機器允許的深度,將丟擲 StackOverFlowError 異常(棧溢位),如果虛擬機器棧可以動態擴充套件(現在大部分 Java 虛擬機器都可以動態擴充套件,只不過 Java 虛擬機器規範中也允許固定長度的 Java 虛擬機器棧),如果擴充套件時無法申請到足夠的記憶體空間,就會丟擲 OutOfmMemoryError 異常(沒有足夠的記憶體)
  4. 本地方法棧:類似 Java 方法的執行有虛擬機器棧,本地方法的執行則對應有本地方法棧

  5. 方法區:用於儲存已被虛擬機器載入的類資訊,常量,靜態變數,即時編譯器編譯後的程式碼等資料。執行緒共享(看儲存的資料就知道了)

  6. Java 堆(Heap):堆的主要作用是存放程式執行過程中建立的物件例項,因為要存放的物件例項有可能會極多,因此也是虛擬機器記憶體管理中最大的一塊。並且由於硬體條件有限,所以需要不斷回收已“無用”的例項物件來騰出空間給新生成的例項物件;因此 Java 的垃圾回收主要是針對堆進行回收的(還有方法區的常量池),Java 堆很多時候也被稱為GC堆(Garbage Collected Heap)。

  7. 類載入機制(Class Loader):類載入子系統是根據一個類的全限定名來載入該類的二進位制流到記憶體中,在JVM 中將形成一份描述 Class 結構的元資訊物件(方法區),通過該元資訊物件可以獲知 Class 的結構資訊:如建構函式,屬性和方法等,Java 允許使用者藉由這個 Class 相關的元資訊物件間接呼叫 Class 物件的功能。

JVM是如何工作的?

如上面的體系結構圖所示,JVM 分為三個主要的子系統:

  1. 類載入器子系統
  2. 執行時資料區
  3. 執行引擎

1、類載入器子系統

Java的動態類載入功能是由類載入器子系統處理的。它負責載入、連結,並且在執行時首次引用類的時候初始化類,而不是在編譯期間。

1.1、載入

這個元件負責載入類。BootStrap類載入器、Extension類載入器和Application類載入器是實現這個功能的三大類載入器。

  1. BootStrap類載入器 —— 負責從classpath載入類,如果沒有類存在,將只加載rt.jar。這個載入器的優先順序最高。
  2. Extension類載入器 —— 負責載入**擴充套件資料夾(jre\lib)**中的類。
  3. Application類載入器 —— 負責載入應用級classpath和環境變數指向的路徑下的類。

上述類載入器在載入類檔案時遵循委託層次結構演算法

1.2、連結

  1. 校驗 —— 位元組碼驗證器將校驗生成的位元組碼是否正確,如果校驗失敗,我們將獲得校驗錯誤資訊
  2. 準備 —— 對於所有的靜態變數,記憶體將被申請並分配預設值。
  3. 解析 —— 所有標記的記憶體引用方法區域被替換成的原始引用

1.3、初始化

這是類載入的最後階段,所有的靜態變數都將被分配原值,靜態程式碼塊將被執行。

執行時資料區

執行時資料區被劃分為五個主要部分:

  • 方法區 —— 所有類級資料都將儲存在這裡,包括靜態變數。每一個JVM只有一個方法區,並且它是一個共享資源。
  • 堆區 —— 所有物件及其對應的例項變數陣列等儲存在此,每個JVM同樣只有一個堆區。由於方法區堆區是多執行緒記憶體共享,因此儲存的資料是非執行緒安全的。
  • 棧區 —— 每個執行緒都會建立一個單獨的執行時棧。在每一次方法呼叫,都會在棧記憶體中建立一個棧幀(Stack Frame)。所有區域性變數將在棧記憶體中建立。棧區是執行緒安全的,因為它不是一個共享資源。棧幀可以被劃分為三個實體:

區域性變數陣列 —— 與方法中有多少區域性變數有關,相應的值將儲存在此處。 運算元棧 —— 如果任何的中間操作需要被執行,運算元棧將作為執行時工作區來執行操作。 幀資料 —— 與方法相對應的所有符號儲存在此。在任何異常情況下,catch塊的資訊被保留在幀資料中。

  • PC暫存器 —— 每一個執行緒都有單獨的PC暫存器,一旦執行指令,PC暫存器將被下一條指令更新,儲存當前執行指令的地址。
  • 本地方法棧 —— 本地方法棧儲存本地方法資訊,每一個執行緒都會建立一個單獨的本地方棧。

3、執行引擎

分配到執行時資料區的位元組碼將被執行引擎執行。執行引擎讀取位元組碼並逐一執行。

  • 直譯器 —— 直譯器能更加快速地解釋位元組碼,但是執行緩慢。直譯器的缺點是當多次呼叫一個方法時,每次都要重新解釋。
  • JIT編譯器 —— JIT編譯器彌補瞭解釋器的不足。執行引擎使用直譯器來轉換位元組碼,當它發現重複的程式碼時,它將使用JIT編譯器來編譯整個位元組碼並轉換為原生代碼。原生代碼將直接被重複的方法所呼叫,從而提高系統性能。
  • 中間程式碼生成器 —— 生成中間程式碼。
  • 程式碼優化器 —— 負責優化上述生成的中間程式碼。
  • 目的碼生成器 —— 負責生成機器碼或者原生代碼。
  • 分析器 —— 一個特殊的元件,負責查詢熱點程式碼,比如一個方法是否被呼叫多次。
  • 垃圾回收器 —— 回收並刪除未引用的物件。可以通過呼叫**System.gc()**來觸發垃圾回收,但不能保證它執行。JVM的垃圾回收是回收被建立的物件。

Java本地介面(JNI)JNI本地方法庫互動,併為執行引擎提供本地方法庫

本地方法庫(Native Method Libraries):它是執行引擎所需的本地庫集合。

1. 執行時資料區域

1. 程式計數器(執行緒私有)

記錄正在執行的虛擬機器位元組碼指令的地址(如果正在執行的是本地方法則為空)。

  • 多個執行緒競爭時被掛起,程式計數器記錄執行到哪裡
  • 唯一一個在 Java 虛擬機器規範中沒有規定任何 OutOfMemoryError 情況的區域

2. 虛擬機器棧(執行緒私有)

每個 Java 方法在執行的同時會建立一個棧幀用於儲存區域性變量表、運算元棧、常量池引用等資訊,從呼叫直至執行完成的過程,就對應著一個棧幀在 Java 虛擬機器棧中入棧和出棧的過程。

  • 補充:棧幀中還存在動態連結、出口(返回地址)等。

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

java -Xss=512M HackTheJava

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

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

3. 本地方法棧(執行緒私有)

本地方法一般是用其它語言(C、C++ 或組合語言等)編寫的,並且被編譯為基於本機硬體和作業系統的程式,對待這些方法需要特別處理。

本地方法棧與 Java 虛擬機器棧類似,它們之間的區別只不過是本地方法棧為本地方法服務。

4. 堆

所有物件例項都在這裡分配記憶體。

是垃圾收集的主要區域(“GC 堆”)。現代的垃圾收集器基本都是採用分代收集演算法(因為物件的生命週期不一樣),主要思想是針對不同的物件採取不同的垃圾回收演算法。虛擬機器把 Java 堆分成以下三塊:

新生代 (Young Generation)

  • 在方法中去 new 一個物件,那這方法呼叫完畢後,物件就會被回收,這就是一個典型的新生代物件。

老年代 (Old Generation)

  • 在新生代中經歷了 N 次垃圾回收後仍然存活的物件就會被放到老年代中。而且大物件直接進入老年代
  • 當 Survivor 空間不夠用時,需要依賴於老年代進行分配擔保,所以大物件直接進入老年代

永久代 (Permanent Generation)

  • 即方法區。

當一個物件被建立時,它首先進入新生代,之後有可能被轉移到老年代中。

新生代存放著大量的生命很短的物件,因此新生代在三個區域中垃圾回收的頻率最高。為了更高效地進行垃圾回收,把新生代繼續劃分成以下三個空間:

  • Eden(伊甸園)
  • From Survivor(倖存者)
  • To Survivor

Java 堆不需要連續記憶體,並且可以動態增加其記憶體,增加失敗會丟擲 OutOfMemoryError 異常。

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

java -Xms=1M -Xmx=2M HackTheJava
  • 思考:為什麼是 8:1:1

5. 方法區

用於存放已被載入的類資訊(包含:類版本、欄位、方法、介面)、常量 (final)、靜態變數 (static)、即時編譯器 (JIT) 編譯後的程式碼等資料。因為都是共享的資料,所有要放在方法區。

和 Java 堆一樣不需要連續的記憶體,並且可以動態擴充套件,動態擴充套件失敗一樣會丟擲 OutOfMemoryError 異常。

對這塊區域進行垃圾回收的主要目標是對常量池的回收和對類的解除安裝,但是一般比較難實現。

JDK 1.7 之前,HotSpot 虛擬機器把它當成永久代來進行垃圾回收,JDK 1.8 之後,取消了永久代,用 metaspace(元資料)區替代。

6. 執行時常量池

執行時常量池是方法區的一部分。

Class 檔案中的常量池(編譯器生成的各種字面量和符號引用)會在類載入後被放入這個區域。

除了在編譯期生成的常量,還允許動態生成,例如 String 類的 intern()。

在TLAB空間中存在

// 位元組碼常量
String s1 = "123";
String s2 = "123";
System.out.println(s1 == s1);  //  true

7. 直接記憶體

在 JDK 1.4 中新加入了 NIO 類,它可以使用 Native 函式庫直接分配堆外記憶體,然後通過一個儲存在 Java 堆裡的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆中來回複製資料。

2. 判斷一個物件是否可被回收

程式計數器、虛擬機器棧和本地方法棧這三個區域屬於執行緒私有的,只存在於執行緒的生命週期內,執行緒結束之後也會消失,因此不需要對這三個區域進行垃圾回收。垃圾回收主要是針對 Java 堆和方法區進行。

1. 引用計數演算法

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

**缺陷:**很難解決物件間相互迴圈引用的問題

2. 可達性分析演算法

通過 GC Roots 作為起始點進行搜尋,能夠到達到的物件都是存活的,不可達的物件可被回收。

★ GC用的引用可達性分析演算法中,哪些物件可作為GC Roots物件?

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

3. 引用型別

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

在 JDK 1.2 之後,Java 對引用的概念進行了擴充,將引用分為 強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。

1. 強引用(Strong Reference)

被強引用關聯的物件不會被回收。

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

Object obj = new Object();

2. 軟引用(Soft Reference)

被軟引用關聯的物件只有在記憶體不夠的情況下才會被回收。

使用 SoftReference 類來建立軟引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使物件只被軟引用關聯

3. 弱引用(Weak Reference)

被弱引用關聯的物件一定會被回收,也就是說它只能存活到下一次垃圾回收發生之前。

使用 WeakReference 類來實現弱引用。

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

4. 虛引用(Phantom Reference)

又稱為幽靈引用或者幻影引用。一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用取得一個物件。

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

使用 PhantomReference 來實現虛引用。

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

4. 方法區的回收

Java虛擬機器規範中確實說過可以不要求虛擬機器在方法區實現垃圾收集,而且在方法區中進行垃圾收集的 “價效比” 一般比較低:在堆中,尤其在新生代中,常規的應用一次垃圾收集一般可以回收 70% ~ 95%的空間,而永久代的垃圾收集效率遠低於此。

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

  • 回收廢棄常量與回收 Java 堆中的物件非常類似。
  • 要判定一個類是否是 “無用的類” 的條件相對苛刻許多。類需要同時滿足下面3個條件才能算 “無用的類”
    • 該類的所有例項都已經被回收。
    • 載入該類的 ClassLoader 已經被回收。
    • 該類對應的 java.lang.Class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

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

5. finalize()

finalize() 類似 C++ 的解構函式,用來做關閉外部資源等工作。但是 try-finally 等方式可以做的更好,並且該方法執行代價高昂,不確定性大,無法保證各個物件的呼叫順序,因此最好不要使用。

當一個物件可被回收時,如果需要執行該物件的 finalize() 方法,那麼就有可能通過在該方法中讓物件重新被引用,從而實現自救。自救只能進行一次,如果回收的物件之前呼叫了 finalize() 方法自救,後面回收時不會呼叫 finalize() 方法。

3. 垃圾收集演算法(垃圾處理方法)

1. 標記 - 清除

首先標記出所有需要回收的物件,在標記完成後統一回收所有標記的物件。

不足:

  • 效率問題:標記和清除的效率都不高
  • 空間問題:標記清除之後會產生大量不連續的記憶體碎片,導致以後需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另外一次垃圾收集。

2. 標記 - 整理

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率會變低。更關鍵的是,如果不想浪費 50% 的空間,就需要有額外的空間進行分配擔保,所以老年代一般不能直接選用這種演算法。

讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

3. 複製回收

將記憶體劃分為大小相等的兩塊,每次只使用其中一塊,當這一塊記憶體用完了就將還存活的物件複製到另一塊上面,然後再把使用過的記憶體空間進行一次清理。

主要不足是隻使用了記憶體的一半。

現在的商業虛擬機器都採用這種收集演算法來回收新生代,但是並不是將新生代劃分為大小相等的兩塊,而是分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 空間和其中一塊 Survivor。在回收時,將 Eden 和 Survivor 中還存活著的物件一次性複製到另一塊 Survivor 空間上,最後清理 Eden 和使用過的那一塊 Survivor。

HotSpot 虛擬機器的 Eden 和 Survivor 的大小比例預設為 8:1,保證了記憶體的利用率達到 90%。如果每次回收有多於 10% 的物件存活,那麼一塊 Survivor 空間就不夠用了,此時需要依賴於老年代進行分配擔保,也就是借用老年代的空間儲存放不下的物件。

★ 分代收集

現在的商業虛擬機器採用分代收集演算法,它根據物件存活週期將記憶體劃分為幾塊,不同塊採用適當的收集演算法。

一般將堆分為新生代和老年代。

  • 新生代使用:複製演算法
  • 老年代使用:標記 - 清除 或者 標記 - 整理 演算法

4. 垃圾收集器

以上是 HotSpot 虛擬機器中的 7 個垃圾收集器,連線表示垃圾收集器可以配合使用。

  • 單執行緒與多執行緒:單執行緒指的是垃圾收集器只使用一個執行緒進行收集,而多執行緒使用多個執行緒;
  • 序列與並行:序列指的是垃圾收集器與使用者程式交替執行,這意味著在執行垃圾收集的時候需要停頓使用者程式;並形指的是垃圾收集器和使用者程式同時執行。除了 CMS 和 G1 之外,其它垃圾收集器都是以序列的方式執行。

1. Serial

Serial 翻譯為序列,也就是說它以序列的方式執行。

它是單執行緒的收集器,只會使用一個執行緒進行垃圾收集工作。

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

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

2. ParNew

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

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

在JDK1.5 時期,HotSpot 推出了 CMS 收集器(Concurrent Mark Sweep),它是 HotSpot 虛擬機器中第一款真正意義上的併發收集器。不幸的是,CMS 作為老年代的收集器,卻無法與 JDK1.4.0 中已經存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5中使用 CMS 來收集老年代的時候,新生代只能選擇 ParNew 或者 Serial 收集器中的一個

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

Parallel Scavenge 收集器以及後面提到的 G1 收集器都沒有使用傳統的 GC 收集器程式碼框架,而另外獨立實現,其餘集中收集器則共用了部分的框架程式碼。

3. Parallel Scavenge

Parallel Scavenge 收集器是一個新生代收集器,它也是使用複製演算法的收集器,又是並行的多執行緒收集器。

與 ParNew 的不同之處:

其它收集器關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間(響應時間),而它的目標是達到一個可控制的吞吐量,它被稱為 吞吐量優先收集器

吞吐量指 CPU 用於執行使用者程式碼的時間佔總時間的比值

吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間)

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

縮短停頓時間是以犧牲吞吐量和新生代空間來換取的:新生代空間變小,垃圾回收變得頻繁,導致吞吐量下降。

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

Parallel Scavenge 收集器氣提供了兩個引數用於精確控制吞吐量

  • 最大垃圾收集停頓時間: -XX:MaxGCPauseMills
  • 吞吐量大小:-XX:GCTimeRatio

MaxGCPauseMills 引數允許的值是一個大於0的毫秒數,收集器將盡可能地保證記憶體回收所花費的時間不超過設定值。但 GC 的停頓時間縮短是以犧牲吞吐量和新生代空間來換取的。停頓時間下降,但吞吐量也降下來了。

GCTimeRatio 引數的值是一個大於0且小於100的整數,也就是垃圾收集時間佔總時間的比例,相當於吞吐量的倒數。區間 1/(1+99) ~ 1/(1+1),即 1% ~ 50%。

由於與吞吐量關係密切,Parallel Scavenge 收集器也經常稱為 “吞吐量優先“ 收集器。

-XX:+UserAdaptiveSizePolicy: GC 自適應調節策略(GC Ergonomics),開啟引數後,就不需要手工指定新生代的大小(-Xmn)、Eden 與 Survivor 區的比例(-XX:SurvivorRatio)、晉升老年代物件的年齡(-XX:PretenureSizeThreshold)等細節引數了。

4. Serial Old

Serial Old 是 Serial 收集器的老年代版本,它同樣是一個單執行緒收集器,使用 ”標記-整理“ 演算法。

這個收集器的主要意義也是在於給 Client 模式下的虛擬機器使用。如果在 Server 模式下,那麼它主要還有兩大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 誕生以前)中與 Parallel Scavenge 收集器搭配使用。

  • 作為 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用。

5. Parallel Old

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多執行緒和 ”標記-整理“ 演算法。

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

6. CMS

CMS(Concurrent Mark Sweep),Mark Sweep 指的是 標記 - 清除 演算法。CMS 是一款優秀的收集器,主要優點:併發收集、低停頓,Sun公司也稱之為併發低停頓收集器(Concurrent Low Pause Collection)。

特點:併發收集、低停頓。

分為以下四個流程:

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

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

具有以下缺點:

  • 吞吐量低:低停頓時間是以犧牲吞吐量為代價的,導致 CPU 利用率不夠高。

  • 無法處理浮動垃圾,可能出現 Concurrent Mode Failure。浮動垃圾是指併發清除階段由於使用者執行緒繼續執行而產生的垃圾,這部分垃圾只能到下一次 GC 時才能進行回收。由於浮動垃圾的存在,因此需要預留出一部分記憶體,意味著 CMS 收集不能像其它收集器那樣等待老年代快滿的時候再回收。如果預留的記憶體不夠存放浮動垃圾,就會出現 Concurrent Mode Failure,這時虛擬機器將臨時啟用 Serial Old 來替代 CMS。

  • 標記 - 清除演算法導致的空間碎片,往往出現老年代空間剩餘,但無法找到足夠大連續空間來分配當前物件,不得不提前觸發一次 Full GC。

    • CMS 提供了一個開關引數 -XX:+UseCMSCompactAtFullCollection(預設開啟),用於在 CMS 收集器頂不住要進行 Full GC 時開啟記憶體碎片的合併整理過程,記憶體整理的過程是無法併發的。
    • 引數 -XX:CMSFullGCsBeforeCompaction 用於設定執行多少次不壓縮的 Full GC後,跟著來以此帶壓縮的,(預設值為0)

7. G1

G1的第一篇paper(附錄1)發表於2004年,在2012年才在jdk1.7u4中可用。oracle官方計劃在jdk9中將G1變成預設的垃圾收集器,以替代CMS。

  • 為何oracle要極力推薦G1呢,G1有哪些優點?
    • 首先,G1的設計原則就是簡單可行的效能調優
    • 其次,G1將新生代,老年代的物理空間劃分取消了**。**

G1(Garbage-First),它是一款面向服務端應用的垃圾收集器,在多 CPU 和大記憶體的場景下有很好的效能。HotSpot 開發團隊賦予它的使命是未來可以替換掉 CMS 收集器。

堆被分為新生代和老年代,其它收集器進行收集的範圍都是整個新生代或者老年代,而 G1 可以直接對新生代和老年代一起回收。

G1 把堆劃分成多個大小相等的獨立區域(Region),新生代和老年代不再物理隔離。

通過引入 Region 的概念,從而將原來的一整塊記憶體空間劃分成多個的小空間,使得每個小空間可以單獨進行垃圾回收。這種劃分方法帶來了很大的靈活性,使得可預測的停頓時間模型成為可能。通過記錄每個 Region 垃圾回收時間以及回收所獲得的空間(這兩個值是通過過去回收的經驗獲得),並維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region。

每個 Region 都有一個 Remembered Set,用來記錄該 Region 物件的引用物件所在的 Region。通過使用 Remembered Set,在做可達性分析的時候就可以避免全堆掃描。

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

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

具備如下特點:

  • 空間整合:整體來看是基於“標記 - 整理”演算法實現的收集器,從區域性(兩個 Region 之間)上來看是基於“複製”演算法實現的,這意味著執行期間不會產生記憶體空間碎片。
  • 可預測的停頓:能讓使用者明確指定在一個長度為 M 毫秒的時間片段內,消耗在 GC 上的時間不得超過 N 毫秒。

詳情請參考:

8. 比較

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

5. 記憶體分配與回收策略

1. 什麼時候進行MinGC,FullGC

  • Minor GC:發生在新生代上,因為新生代物件存活時間很短,因此 Minor GC 會頻繁執行,執行的速度一般也會比較快。
    • 新生代中的垃圾收集動作,採用的是複製演算法
    • 對於較大的物件,在 Minor GC 的時候可以直接進入老年代
  • Full GC:發生在老年代上,老年代物件其存活時間長,因此 Full GC 很少執行,執行速度會比 Minor GC 慢很多。
    • Full GC 是發生在老年代的垃圾收集動作,採用的是 標記-清除/整理 演算法。
    • 由於老年代的物件幾乎都是在 Survivor 區熬過來的,不會那麼容易死掉。因此 Full GC 發生的次數不會有 Minor GC 那麼頻繁,並且 Time(Full GC)>Time(Minor GC)

2. 記憶體分配策略

1. 物件優先在 Eden 分配

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

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

大物件是指需要連續記憶體空間的物件,最典型的大物件是那種很長的字串以及陣列。

經常出現大物件會提前觸發垃圾收集以獲取足夠的連續空間分配給大物件。

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

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

為物件定義年齡計數器,物件在 Eden 出生並經過 Minor GC 依然存活,將移動到 Survivor 中,年齡就增加 1 歲,增加到一定年齡則移動到老年代中。

-XX:MaxTenuringThreshold 用來定義年齡的閾值。

4. 動態物件年齡判定

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

5. 空間分配擔保

在發生 Minor GC 之前,虛擬機器先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果條件成立的話,那麼 Minor GC 可以確認是安全的。

如果不成立的話虛擬機器會檢視 HandlePromotionFailure 設定值是否允許擔保失敗,如果允許那麼就會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行一次 Minor GC;如果小於,或者 HandlePromotionFailure 設定不允許冒險,那麼就要進行一次 Full GC。

3. Full GC 的觸發條件

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

1. 呼叫 System.gc()

只是建議虛擬機器執行 Full GC,但是虛擬機器不一定真正去執行。不建議使用這種方式,而是讓虛擬機器管理記憶體。

2. 老年代空間不足

老年代空間不足的常見場景為前文所講的大物件直接進入老年代、長期存活的物件進入老年代等。

為了避免以上原因引起的 Full GC,應當儘量不要建立過大的物件以及陣列。除此之外,可以通過 -Xmn 虛擬機器引數調大新生代的大小,讓物件儘量在新生代被回收掉,不進入老年代。還可以通過 -XX:MaxTenuringThreshold 調大物件進入老年代的年齡,讓物件在新生代多存活一段時間。

3. 空間分配擔保失敗

使用複製演算法的 Minor GC 需要老年代的記憶體空間作擔保,如果擔保失敗會執行一次 Full GC。具體內容請參考上面的第五小節。

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

在 JDK 1.7 及以前,HotSpot 虛擬機器中的方法區是用永久代實現的,永久代中存放的為一些 Class 的資訊、常量、靜態變數等資料。

當系統中要載入的類、反射的類和呼叫的方法較多時,永久代可能會被佔滿,在未配置為採用 CMS GC 的情況下也會執行 Full GC。如果經過 Full GC 仍然回收不了,那麼虛擬機器會丟擲 java.lang.OutOfMemoryError。

為避免以上原因引起的 Full GC,可採用的方法為增大永久代空間或轉為使用 CMS GC。

5. Concurrent Mode Failure

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

6. 類載入機制

58同城面試,這裡好好的再把類載入機制深入一下吧

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

懶載入:要用的時候再去載入

類的生命週期

包括以下 7 個階段:

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

其中解析過程在某些情況下可以在初始化階段之後再開始,這是為了支援 Java 的動態繫結。

這 7 個階段中的:載入、驗證、準備、初始化、解除安裝的順序是固定的。但它們並不一定是嚴格同步序列執行,它們之間可能會有交叉,但總是以 “開始” 的順序總是按部就班的。至於解析則有可能在初始化之後才開始,這是為了支援 Java 語言的執行時繫結(也稱為動態繫結或晚期繫結)。

類初始化時機

1. 主動引用

虛擬機器規範中並沒有強制約束何時進行載入,但是規範嚴格規定了有且只有下列五種情況必須對類進行初始化(載入、驗證、準備都會隨之發生):

  • 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時,如果類沒有進行過初始化,則必須先觸發其初始化。最常見的生成這 4 條指令的場景是:使用 new 關鍵字例項化物件的時候;讀取或設定一個類的靜態欄位(被 final 修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候;以及呼叫一個類的靜態方法的時候。
  • 使用 java.lang.reflect 包的方法對類進行反射呼叫的時候,如果類沒有進行初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含 main() 方法的那個類),虛擬機器會先初始化這個主類;
  • 當使用 JDK 1.7 的動態語言支援時,如果一個 java.lang.invoke.MethodHandle 例項最後的解析結果為 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化;

2. 被動引用

以上 5 種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。被動引用的常見例子包括:

  • 通過子類引用父類的靜態欄位,不會導致子類初始化。
System.out.println(SubClass.value);  // value 欄位在 SuperClass 中定義
  • 通過陣列定義來引用類,不會觸發此類的初始化。該過程會對陣列類進行初始化,陣列類是一個由虛擬機器自動生成的、直接繼承自 Object 的子類,其中包含了陣列的屬性和方法。
SuperClass[] sca = new SuperClass[10];
  • 常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。
System.out.println(ConstClass.HELLOWORLD);

類載入過程

包含了載入、驗證、準備、解析和初始化這 5 個階段。

1. 載入

載入是類載入的一個階段,注意不要混淆。

載入過程完成以下三件事:

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

載入源(其中二進位制位元組流可以從以下方式中獲取):

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

2. 驗證

目的:確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

  • 檔案格式驗證:驗證位元組流是否符合 Class 檔案格式的規範,並且能被當前版本的虛擬機器處理。

    • 是否以0xCAFEBABE開頭,前四個位元組為魔數
    • 版本號是否合理,如:JDK1.8(52.0)、JDK1.7(51.0)
  • 元資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求。

    • 是否有父類-繼
    • 繼承了 final 類?
    • 非抽象類實現了所有的抽象方法
  • 位元組碼驗證(很複雜):通過資料流和控制流分析,確保程式語義是合法、符合邏輯的。

    • 執行檢查
    • 棧資料型別和操作碼資料引數吻合
    • 跳轉指令指定到合理的位置
  • 符號引用驗證:發生在虛擬機器將符號引用轉換為直接引用的時候,對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗。

    • 常量池中描述類是否存在
    • 訪問的方法或欄位是否存在且有足夠的許可權

3. 準備

準備階段正式為類變數分配記憶體並設定變數的初始值。這些變數使用的記憶體都將在方法區中進行分配。類變數是被 static 修飾的變數,準備階段為類變數分配記憶體並設定初始值,使用的是方法區的記憶體。

例項變數不會在這階段分配記憶體,它將會在物件例項化時隨著物件一起分配在堆中。注意,例項化不是類載入的一個過程,類載入發生在所有例項化操作之前,並且類載入只進行一次,例項化可以進行多次。

初始值一般為 0 值,例如下面的類變數 value 被初始化為 0 而不是 123,在初始化的 <clinit> 中才會被設定為1。

  • 預設值:int 0, boolean false, float 0.0, char ‘0’, 抽象資料型別 null
public static int value = 123;
  • 對於 static final 型別,在準備階段就會被賦上正確的值
public static final int value = 123;

4. 解析

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

  • 類或介面的解析
  • 欄位解析
  • 類方法解析
  • 介面方法解析

什麼是符號引用和直接引用?

  • 符號引用:符號引用是一組符號來描述所引用的目標物件,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標物件並不一定已經載入到記憶體中。
  • 直接引用:直接引用可以是直接指向目標物件的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是與虛擬機器記憶體佈局實現相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經在記憶體中存在。

參考資料:

5. 初始化

初始化階段才真正開始執行類中定義的 Java 程式程式碼。初始化階段即虛擬機器執行類構造器 <clinit>() 方法的過程。

在準備階段,類變數已經賦過一次系統要求的初始值,而在初始化階段,根據程式設計師通過程式制定的主觀計劃去初始化類變數和其它資源。

<clinit>() 方法具有以下特點:

  • 是由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序由語句在原始檔中出現的順序決定。特別注意的是,靜態語句塊只能訪問到定義在它之前的類變數,定義在它之後的類變數只能賦值,不能訪問。例如以下程式碼:
public class Test {
    static {
        i = 0;                // 給變數賦值可以正常編譯通過
        System.out.print(i);  // 這句編譯器會提示“非法向前引用”
    }
    static int i = 1;
}
  • 與類的建構函式(或者說例項構造器 <init>())不同,不需要顯式的呼叫父類的構造器。虛擬機器會自動保證在子類的 <clinit>() 方法執行之前,父類的 <clinit>() 方法已經執行結束。因此虛擬機器中第一個執行 <clinit>() 方法的類肯定為 java.lang.Object。
  • 由於父類的 <clinit>() 方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。例如以下程式碼:
static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
     System.out.println(Sub.B);  // 2
}
  • <clinit>() 方法對於類或介面不是必須的,如果一個類中不包含靜態語句塊,也沒有對類變數的賦值操作,編譯器可以不為該類生成 <clinit>() 方法。
  • 介面中不可以使用靜態語句塊,但仍然有類變數初始化的賦值操作,因此介面與類一樣都會生成 <clinit>() 方法。但介面與類不同的是,執行介面的 <clinit>() 方法不需要先執行父介面的 <clinit>() 方法。只有當父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的 <clinit>() 方法。
  • 虛擬機器會保證一個類的 <clinit>() 方法在多執行緒環境下被正確的加鎖和同步,如果多個執行緒同時初始化一個類,只會有一個執行緒執行這個類的 <clinit>() 方法,其它執行緒都會阻塞等待,直到活動執行緒執行 <clinit>() 方法完畢。如果在一個類的 <clinit>() 方法中有耗時的操作,就可能造成多個執行緒阻塞,在實際過程中此種阻塞很隱蔽。

類載入器

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

1. 類與類載入器

兩個類相等:只有被同一個類載入器載入的類才可能會相等。相同的位元組碼被不同的類載入器載入的類不相等。

這裡的相等,包括類的 Class 物件的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果為 true,也包括使用 instanceof 關鍵字做物件所屬關係判定結果為 true。

2. 類載入器分類

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

  • 啟動類載入器(Bootstrap ClassLoader),這個類載入器用 C++ 實現,是虛擬機器自身的一部分;
  • 所有其他類的載入器