備戰- Java虛擬機器

濤姐濤哥發表於2021-07-22

備戰- Java虛擬機器

 

    試問嶺南應不好,卻道,此心安處是吾鄉。

 

簡介:備戰- Java虛擬機器

一、執行時資料區域

程式計算器、Java 虛擬機器棧、本地方法棧、堆、方法區

在Java 執行環境參考連結:https://www.cnblogs.com/taojietaoge/p/10264416.html

直接記憶體

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

二、垃圾收集

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

引用計數演算法

為物件新增一個引用計數器,當物件增加一個引用時計數器加 1,引用失效時計數器減 1。引用計數為 0 的物件可被回收。

在兩個物件出現迴圈引用的情況下,此時引用計數器永遠不為 0,導致無法對它們進行回收。正是因為迴圈引用的存在,因此 Java 虛擬機器不使用引用計數演算法。

備戰- Java虛擬機器
 1 public class Test {
 2 
 3     public Object instance = null;
 4 
 5     public static void main(String[] args) {
 6         Test a = new Test();
 7         Test b = new Test();
 8         a.instance = b;
 9         b.instance = a;
10         a = null;
11         b = null;
12         doSomething();
13     }
14 }
View Code

在上述程式碼中,a 與 b 引用的物件例項互相持有了物件的引用,因此當我們把對 a 物件與 b 物件的引用去除之後,由於兩個物件還存在互相之間的引用,導致兩個 Test 物件無法被回收。

可達性分析演算法

以 GC Roots 為起始點進行搜尋,可達的物件都是存活的,不可達的物件可被回收。

Java 虛擬機器使用該演算法來判斷物件是否可被回收,GC Roots 一般包含以下內容:

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

方法區的回收

因為方法區主要存放永久代物件,而永久代物件的回收率比新生代低很多,所以在方法區上進行回收價效比不高。

主要是對常量池的回收和對類的解除安裝。

為了避免記憶體溢位,在大量使用反射和動態代理的場景都需要虛擬機器具備類解除安裝功能。

類的解除安裝條件很多,需要滿足以下三個條件,並且滿足了條件也不一定會被解除安裝:

  • 該類所有的例項都已經被回收,此時堆中不存在該類的任何例項。
  • 載入該類的 ClassLoader 已經被回收。
  • 該類對應的 Class 物件沒有在任何地方被引用,也就無法在任何地方通過反射訪問該類方法。

finalize()

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

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

引用型別

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

Java 提供了四種強度不同的引用型別。

1. 強引用

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

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

Object obj = new Object();

2. 軟引用

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

使用 SoftReference 類來建立軟引用。

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

3. 弱引用

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

使用 WeakReference 類來建立弱引用。

1 Object obj = new Object();
2 WeakReference<Object> wf = new WeakReference<Object>(obj);
3 obj = null;  // 使obj 物件只被弱引用關聯

4. 虛引用

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

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

使用 PhantomReference 來建立虛引用。

1 Object obj = new Object();
2 PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
3 obj = null;  // 使obj 只能被虛引用關聯

垃圾收集演算法

1. 標記 - 清除

標記清除演算法(Mark-Sweep)是最基礎的一種垃圾回收演算法,它分為2部分,先把記憶體區域中的這些物件進行標記,哪些屬於可回收標記出來,然後把這些垃圾拎出來清理掉。就像上圖一樣,清理掉的垃圾就變成未使用的記憶體區域,等待被再次使用。

這邏輯再清晰不過了,並且也很好操作,但它存在一個很大的問題,那就是記憶體碎片。

上圖中等方塊的假設是 2M,小一些的是 1M,大一些的是 4M。等我們回收完,記憶體就會切成了很多段。我們知道開闢記憶體空間時,需要的是連續的記憶體區域,這時候我們需要一個 2M的記憶體區域,其中有2個 1M 是沒法用的。這樣就導致,其實我們本身還有這麼多的記憶體的,但卻用不了。

不足:

  • 標記和清除過程效率都不高;
  • 會產生大量不連續的記憶體碎片,導致無法給大物件分配記憶體。

2. 標記 - 整理

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

標記整理演算法一方面在標記-清除演算法上做了升級,解決了記憶體碎片的問題,也規避了複製演算法只能利用一半記憶體區域的弊端。看起來很美好,但從上圖可以看到,它對記憶體變動更頻繁,需要整理所有存活物件的引用地址,在效率上比複製演算法要差很多。

優點:

  • 不會產生記憶體碎片

不足:

  • 需要移動大量物件,處理效率比較低。

3. 複製

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

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

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

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

4. 分代收集

分代收集演算法(Generational Collection)嚴格來說並不是一種思想或理論,而是融合上述3種基礎的演算法思想,而產生的針對不同情況所採用不同演算法的一套組合拳。物件存活週期的不同將記憶體劃分為幾塊。一般是把 Java 堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用標記-清理或者標記——整理演算法來進行回收。

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

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

5. GC的分類  

JVM 在進行GC 時,可能針對三個區域進行垃圾回收分別是新生代、老年代、方法區,大部分時候回收的都是新生代。GC型別主要有以下四種型別。

  • 新生代收集(Minor GC/Young GC):只針對新生代的垃圾收集。具體點的是Eden 區滿時觸發GC。 Survivor滿不會觸發Minor GC 。
  • 老年代收集(Major GC/Old GC):只針對老年代的垃圾收集。 目前,只有CMS 收集器會有單獨收集老年代的行為。
  • 混合收集(Mixed GC):指目標是收集整個新生代以及部分老年代的垃圾收集。 目前只有G1收集器會有這種行為。
  • 整堆收集(Full GC):收集整個Java 堆和方法區的垃圾收集。

Java 堆(Java Heap)是JVM所管理的記憶體中最大的一塊,堆又是垃圾收集器管理的主要區域。

Java 堆主要分為2個區域-年輕代與老年代,其中年輕代又分 Eden 區和 Survivor 區,其中 Survivor 區又分 From 和 To 2個區。為什麼要分這麼多個區呢?

★ Eden 區

IBM 公司的專業研究表明,有將近98%的物件是朝生夕死,所以針對這一現狀,大多數情況下,物件會在新生代 Eden 區中進行分配,當 Eden 區沒有足夠空間進行分配時,虛擬機器會發起一次 Minor GC,Minor GC 相比 Major GC 更頻繁,回收速度也更快。
通過 Minor GC 之後,Eden 會被清空,Eden 區中絕大部分物件會被回收,而那些無需回收的存活物件,將會進到 Survivor 的 From 區(若 From 區不夠,則直接進入 Old 區)。

★ Survivor 區

Survivor 區相當於是 Eden 區和 Old 區的一個緩衝,類似於我們交通燈中的黃燈。Survivor 又分為2個區,一個是 From 區,一個是 To 區。每次執行 Minor GC,會將 Eden 區和 From 存活的物件放到 Survivor 的 To 區(如果 To 區不夠,則直接進入 Old 區)。

★ 為什麼要分割槽?

如果沒有 Survivor 區,Eden 區每進行一次 Minor GC,存活的物件就會被送到老年代,老年代很快就會被填滿。而有很多物件雖然一次 Minor GC 沒有消滅,但其實也並不會存活多久,或許第二次,第三次就需要被清除。這時候移入老年區,很明顯不是一個明智的決定。

所以,Survivor 的存在意義就是減少被送到老年代的物件,進而減少 Major GC 的發生。Survivor 的預篩選保證,只有經歷16次 Minor GC 還能在新生代中存活的物件,才會被送到老年代。

★ 為什麼要分兩個Survivor ?

設定兩個 Survivor 區最大的好處就是解決記憶體碎片化。

我們先假設一下,Survivor 如果只有一個區域會怎樣。Minor GC 執行後,Eden 區被清空了,存活的物件放到了 Survivor 區,而之前 Survivor 區中的物件,可能也有一些是需要被清除的。問題來了,這時候我們怎麼清除它們?在這種場景下,我們只能標記清除,而我們知道標記清除最大的問題就是記憶體碎片,在新生代這種經常會消亡的區域,採用標記清除必然會讓記憶體產生嚴重的碎片化。因為 Survivor 有2個區域,所以每次 Minor GC,會將之前 Eden 區和 From 區中的存活物件複製到 To 區域。第二次 Minor GC 時,From 與 To 職責兌換,這時候會將 Eden 區和 To 區中的存活物件再複製到 From 區域,以此反覆。

這種機制最大的好處就是,整個過程中,永遠有一個 Survivor space 是空的,另一個非空的 Survivor space 是無碎片的。那麼,Survivor 為什麼不分更多塊呢?比方說分成三個、四個、五個?顯然,如果 Survivor 區再細分下去,每一塊的空間就會比較小,容易導致 Survivor 區滿,兩塊 Survivor 區可能是經過權衡之後的最佳方案。

★ Old 區

老年代佔據著2/3的堆記憶體空間,只有在 Major GC 的時候才會進行清理,每次 GC 都會觸發“Stop-The-World”。記憶體越大,STW 的時間也越長,所以記憶體也不僅僅是越大就越好。由於複製演算法在物件存活率較高的老年代會進行很多次的複製操作,效率很低,所以老年代這裡採用的是標記——整理演算法。

★ 兩張圖瞭解垃圾回收全流程

   

垃圾收集器

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

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

1. Serial 收集器

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

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

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

它是 Client 場景下的預設新生代收集器,因為在該場景下記憶體一般來說不會很大。它收集一兩百兆垃圾的停頓時間可以控制在一百多毫秒以內,只要不是太頻繁,這點停頓時間是可以接受的。

2. ParNew 收集器

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

它是 Server 場景下預設的新生代收集器,除了效能原因外,主要是因為除了 Serial 收集器,只有它能與 CMS 收集器配合使用。

3. Parallel Scavenge 收集器

與 ParNew 一樣是多執行緒收集器。

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

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

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

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

4. Serial Old 收集器

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

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

5. Parallel Old 收集器

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

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

6. CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是標記 - 清除演算法。

分為以下四個流程:

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

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

具有以下缺點:

  • 吞吐量低:低停頓時間是以犧牲吞吐量為代價的,導致 CPU 利用率不夠高。
  • 無法處理浮動垃圾,可能出現 Concurrent Mode Failure。浮動垃圾是指併發清除階段由於使用者執行緒繼續執行而產生的垃圾,這部分垃圾只能到下一次 GC 時才能進行回收。由於浮動垃圾的存在,因此需要預留出一部分記憶體,意味著 CMS 收集不能像其它收集器那樣等待老年代快滿的時候再回收。如果預留的記憶體不夠存放浮動垃圾,就會出現 Concurrent Mode Failure,這時虛擬機器將臨時啟用 Serial Old 來替代 CMS。
  • 標記 - 清除演算法導致的空間碎片,往往出現老年代空間剩餘,但無法找到足夠大連續空間來分配當前物件,不得不提前觸發一次 Full GC。

7. G1 收集器

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

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

上圖中綠色的永久代在現在的Hotspot 中已被移除。

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 毫秒。

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

Minor GC 和 Full GC

  • Minor GC:回收新生代,因為新生代物件存活時間很短,因此 Minor GC 會頻繁執行,執行的速度一般也會比較快。

  • Full GC:回收老年代和新生代,老年代物件其存活時間長,因此 Full GC 很少執行,執行速度會比 Minor GC 慢很多。

記憶體分配策略

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。

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。具體內容請參考上面的第 5 小節的空間分配擔保。

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。

四、類載入機制

類是在執行期間第一次使用時動態載入的,而不是一次性載入所有類。因為如果一次性載入,那麼會佔用很多的記憶體。

類的生命週期

包括以下 7 個階段:

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

1. 載入

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

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

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

其中二進位制位元組流可以從以下方式中獲取:

  • 從 ZIP 包讀取,成為 JAR、EAR、WAR 格式的基礎。
  • 從網路中獲取,最典型的應用是 Applet。
  • 執行時計算生成,例如動態代理技術,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理類的二進位制位元組流。
  • 由其他檔案生成,例如由 JSP 檔案生成對應的 Class 類。

2. 驗證

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

3. 準備

類變數是被 static 修飾的變數,準備階段為類變數分配記憶體並設定初始值,使用的是方法區的記憶體。

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

初始值一般為 0 值,例如下面的類變數 value 被初始化為 0 而不是 123。

public static int value = 123;   // 變數

如果類變數是常量,那麼它將初始化為表示式所定義的值而不是 0。例如下面的常量 value 被初始化為 123 而不是 0。

public static final int value = 123;  // 常量

4. 解析

將常量池的符號引用替換為直接引用的過程。

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

5. 初始化 

初始化階段才真正開始執行類中定義的 Java 程式程式碼。初始化階段是虛擬機器執行類構造器 <clinit\>() 方法的過程。在準備階段,類變數已經賦過一次系統要求的初始值,而在初始化階段,根據程式設計師通過程式制定的主觀計劃去初始化類變數和其它資源。

<clinit>() 是由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序由語句在原始檔中出現的順序決定。特別注意的是,靜態語句塊只能訪問到定義在它之前的類變數,定義在它之後的類變數只能賦值,不能訪問。例如以下程式碼:

1 public class Test {
2     static {
3         i = 0;                // 給變數賦值可以正常編譯通過
4         System.out.print(i);  // 這句編譯器會提示“非法向前引用”
5     }
6     static int i = 1;
7 }

由於父類的 <clinit>() 方法先執行,也就意味著父類中定義的靜態語句塊的執行要優先於子類。例如以下程式碼:

備戰- Java虛擬機器
 1 static class Parent {
 2     public static int A = 1;
 3     static {
 4         A = 2;
 5     }
 6 }
 7 
 8 static class Sub extends Parent {
 9     public static int B = A;
10 }
11 
12 public static void main(String[] args) {
13      System.out.println(Sub.B);  // 2
14 }
View Code

介面中不可以使用靜態語句塊,但仍然有類變數初始化的賦值操作,因此介面與類一樣都會生成 <clinit>() 方法。但介面與類不同的是,執行介面的 <clinit>() 方法不需要先執行父介面的 <clinit>() 方法。只有當父介面中定義的變數使用時,父介面才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的 <clinit>() 方法。

虛擬機器會保證一個類的 <clinit>() 方法在多執行緒環境下被正確的加鎖和同步,如果多個執行緒同時初始化一個類,只會有一個執行緒執行這個類的 <clinit>() 方法,其它執行緒都會阻塞等待,直到活動執行緒執行 <clinit>() 方法完畢。如果在一個類的 <clinit>() 方法中有耗時的操作,就可能造成多個執行緒阻塞,在實際過程中此種阻塞很隱蔽。

類初始化時機

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);

類與類載入器

兩個類相等,需要類本身相等,並且使用同一個類載入器進行載入。這是因為每一個類載入器都擁有一個獨立的類名稱空間。

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

類載入機制參考連結:https://www.cnblogs.com/taojietaoge/p/10269844.html

類載入器分類

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

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

  • 所有其它類的載入器,使用 Java 實現,獨立於虛擬機器,繼承自抽象類 java.lang.ClassLoader。

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

  • 啟動類載入器(Bootstrap ClassLoader)此類載入器負責將存放在 <JRE_HOME>\lib 目錄中的,或者被 -Xbootclasspath 引數所指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被載入)類庫載入到虛擬機器記憶體中。啟動類載入器無法被 Java 程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給啟動類載入器,直接使用 null 代替即可。

  • 擴充套件類載入器(Extension ClassLoader)這個類載入器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。它負責將 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系統變數所指定路徑中的所有類庫載入到記憶體中,開發者可以直接使用擴充套件類載入器。

  • 應用程式類載入器(Application ClassLoader)這個類載入器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。由於這個類載入器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

雙親委派模型

應用程式是由三種類載入器互相配合從而實現類載入,除此之外還可以加入自己定義的類載入器。

雙親委託參考連結:https://www.cnblogs.com/taojietaoge/p/10269844.html

下圖展示了類載入器之間的層次關係,稱為雙親委派模型(Parents Delegation Model)。該模型要求除了頂層的啟動類載入器外,其它的類載入器都要有自己的父類載入器。這裡的父子關係一般通過組合關係(Composition)來實現,而不是繼承關係(Inheritance)。

1. 工作過程

一個類載入器首先將類載入請求轉發到父類載入器,只有當父類載入器無法完成時才嘗試自己載入。

從上圖可用看出ClassLoader的載入序列,委託是從下往上,查詢過程則是從上向下的,以下有幾個注意事項:

  1. 一個AppClassLoader 查詢資源時,首先會檢視快取是否有,若有則從快取中獲取,否則委託給父載入器。
  2. 重複第一步的遞迴操作,查詢類是否已被載入。
  3. 如果ExtClassLoader 也沒有載入過,則由Bootstrap ClassLoader 載入,它首先也會查詢快取,如果沒有找到的話,就去找自己的規定的路徑下,也就是sun.mic.boot.class 下面的路徑,找到就返回,找不到就讓子載入器自己去找。
  4. Bootstrap ClassLoader 如果沒有查詢成功,則ExtClassLoader 自己在java.ext.dirs 路徑中去查詢,查詢成功就返回,查詢不成功則再向下讓子載入器找。
  5. 若是ExtClassLoader 查詢不成功,則由AppClassLoader 在java.class.path 路徑下自己查詢查詢,找到就返回,如果沒有找到就讓子類找,如果沒有子類則會丟擲各種異常。

2. 好處

使得 Java 類隨著它的類載入器一起具有一種帶有優先順序的層次關係,從而使得基礎類得到統一。

  • 防止重複載入同一個.class。通過委託去向上面問一問,載入過了,就不用再載入一遍。保證資料安全。
  • 保證核心.class不能被篡改。通過委託方式,不會去篡改核心.clas,即使篡改也不會去載入,即使載入也不會是同一個.class物件了。不同的載入器載入同一個.class也不是同一個Class物件。這樣保證了Class執行安全。

3. 實現

以下是抽象類 java.lang.ClassLoader 的程式碼片段,其中的 loadClass() 方法執行過程如下:先檢查類是否已經載入過,如果沒有則讓父類載入器去載入。當父類載入器載入失敗時丟擲 ClassNotFoundException,此時嘗試自己去載入。

備戰- Java虛擬機器
 1 public abstract class ClassLoader {
 2     // The parent class loader for delegation
 3     private final ClassLoader parent;
 4 
 5     public Class<?> loadClass(String name) throws ClassNotFoundException {
 6         return loadClass(name, false);
 7     }
 8 
 9     protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
10         synchronized (getClassLoadingLock(name)) {
11             // First, check if the class has already been loaded
12             Class<?> c = findLoadedClass(name);
13             if (c == null) {
14                 try {
15                     if (parent != null) {
16                         c = parent.loadClass(name, false);
17                     } else {
18                         c = findBootstrapClassOrNull(name);
19                     }
20                 } catch (ClassNotFoundException e) {
21                     // ClassNotFoundException thrown if class not found
22                     // from the non-null parent class loader
23                 }
24 
25                 if (c == null) {
26                     // If still not found, then invoke findClass in order
27                     // to find the class.
28                     c = findClass(name);
29                 }
30             }
31             if (resolve) {
32                 resolveClass(c);
33             }
34             return c;
35         }
36     }
37 
38     protected Class<?> findClass(String name) throws ClassNotFoundException {
39         throw new ClassNotFoundException(name);
40     }
41 }
View Code

4. 自定義類載入器實現

在ClassLoader中有四個很重要實用的方法loadClass()、findLoadedClass()、findClass()、defineClass(),可以用來建立屬於自己的類的載入方式;比如我們需要動態載入一些東西,或者從C盤某個特定的資料夾載入一個class 檔案,又或者從網路上下載class 主內容然後再進行載入等。分三步搞定:

1、編寫一個類繼承ClassLoader 抽象類;

2、重寫findClass() 方法;

3、在findClass() 方法中呼叫defineClass() 方法即可實現自定義ClassLoader。

需求:

自定義一個classloader 其預設載入路徑為"/TJT/Code"下的jar 包和資源。

實現:

首先建立一個Test.java,然後javac 編譯並把生成的Test.class 檔案放到"/TJT/Code" 路徑下。

然後再編寫一個DiskClassLoader 繼承ClassLoader。

備戰- Java虛擬機器
 1 package www.baidu;
 2 import java.io.ByteArrayOutputStream;
 3 import java.io.File;
 4 import java.io.FileInputStream;
 5 import java.io.IOException;
 6 
 7 public class DiskClassLoader extends ClassLoader{
 8 //自定義classLoader能將class二進位制內容轉換成Class物件
 9     private String myPath;
10 
11     public DiskClassLoader(String path) {
12         myPath = path;
13     }
14 
15     //findClass()方法中定義了查詢class的方法
16     @Override
17     protected Class<?> findClass(String name) throws ClassNotFoundException{
18         String fileName = getFileName(name);
19         File file = new File(myPath,fileName);
20         try {
21             FileInputStream is = new FileInputStream(file);
22             ByteArrayOutputStream bos = new ByteArrayOutputStream();
23             int len = 0;
24             try {
25                 while((len = is.read()) != -1) {
26                     bos.write(len);
27                 }
28             } catch (IOException e) {
29                 e.printStackTrace();
30             }
31             byte[] data = bos.toByteArray();
32             is.close();
33             bos.close();
34             //資料通過defineClass()生成了Class物件
35             return defineClass(name, data,0,data.length );
36         } catch (Exception e) {
37             e.printStackTrace();
38         }
39         return super.findClass(name);
40     }
41 
42     private String getFileName(String name) {
43         int lastIndexOf = name.lastIndexOf('.');
44         if (lastIndexOf == -1) {
45             return name + ".class";
46         }else {
47             return name.substring(lastIndexOf + 1) + ".class";
48         }
49     }
50 }
View Code
最後通過FindClassLoader 的測試類,呼叫在Test.class 裡面的一個find() 方法。
備戰- Java虛擬機器
 1 package www.baidu;
 2 import java.lang.reflect.Method;
 3 
 4 public class FindClassLoader {
 5     public static void main(String[] args) throws ClassNotFoundException {
 6         //建立自定義classloader物件
 7         DiskClassLoader diskL = new DiskClassLoader("/TJT/Code");
 8         System.out.println("classloader is: "+diskL);
 9         try {
10              //載入class檔案
11             Class clazz = diskL.loadClass("www.baidu.Test");
12             if (clazz != null) {
13                 Object object = clazz.newInstance();
14                 Method declaredMethod = clazz.getDeclaredMethod("find", null);
15                 //通過反射呼叫Test類的find()方法
16                 declaredMethod.invoke(object, null);
17             }
18         } catch (Exception e) {
19             e.printStackTrace();
20         }
21     }
22 }
View Code

驗證找到指定路徑下的自定義classloader。

 

 

 

 

 

 

  

 

試問嶺南應不好

卻道

此心安處是吾鄉

 

 

 

 

相關文章