重讀 JVM

fitzeng發表於2017-09-04

秋招開始了,前面由於做別的事耽誤了半個月,以前學的東西不用就很容易忘記。所以,這次重新閱讀《深入理解 JVM 虛擬機器》時,想做一個記錄。將碎片的知識整合,方便自己以後閱讀,同時也和大家一起分享。內容中會新增我自己的理解,其中如果有錯誤,歡迎大家指正。

相關閱讀:
1. 重拾資料結構
2. 重拾作業系統
3. 重拾計算機網路(未完成))
注意:以上內容會持續更新,歡迎大家關注 GitHub && Blog

1. Java 記憶體區域與記憶體溢位異常

1.1 執行時資料區域

根據《Java 虛擬機器規範(Java SE 7 版)》規定,Java 虛擬機器所管理的記憶體如下圖所示。

1.1.1 程式計數器

記憶體空間小,執行緒私有。位元組碼直譯器工作是就是通過改變這個計數器的值來選取下一條需要執行指令的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴計數器完成

如果執行緒正在執行一個 Java 方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是 Native 方法,這個計數器的值則為 (Undefined)。此記憶體區域是唯一一個在 Java 虛擬機器規範中沒有規定任何 OutOfMemoryError 情況的區域。

1.1.2 Java 虛擬機器棧

執行緒私有,生命週期和執行緒一致。描述的是 Java 方法執行的記憶體模型:每個方法在執行時都會床建立一個棧幀(Stack Frame)用於儲存區域性變數表運算元棧動態連結方法出口等資訊。每一個方法從呼叫直至執行結束,就對應著一個棧幀從虛擬機器棧中入棧到出棧的過程。

區域性變數表:存放了編譯期可知的各種基本型別(boolean、byte、char、short、int、float、long、double)、物件引用(reference 型別)和 returnAddress 型別(指向了一條位元組碼指令的地址)

StackOverflowError:執行緒請求的棧深度大於虛擬機器所允許的深度。
OutOfMemoryError:如果虛擬機器棧可以動態擴充套件,而擴充套件時無法申請到足夠的記憶體。

1.1.3 本地方法棧

區別於 Java 虛擬機器棧的是,Java 虛擬機器棧為虛擬機器執行 Java 方法(也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。也會有 StackOverflowError 和 OutOfMemoryError 異常。

1.1.4 Java 堆

對於絕大多數應用來說,這塊區域是 JVM 所管理的記憶體中最大的一塊。執行緒共享,主要是存放物件例項和陣列。內部會劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer, TLAB)。可以位於物理上不連續的空間,但是邏輯上要連續。

OutOfMemoryError:如果堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,丟擲該異常。

1.1.5 方法區

屬於共享記憶體區域,儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。

現在用一張圖來介紹每個區域儲存的內容。

1.1.6 執行時常量池

屬於方法區一部分,用於存放編譯期生成的各種字面量和符號引用。編譯器和執行期(String 的 intern() )都可以將常量放入池中。記憶體有限,無法申請時丟擲 OutOfMemoryError。

1.1.7 直接記憶體

非虛擬機器執行時資料區的部分

在 JDK 1.4 中新加入 NIO (New Input/Output) 類,引入了一種基於通道(Channel)和快取(Buffer)的 I/O 方式,它可以使用 Native 函式庫直接分配堆外記憶體,然後通過一個儲存在 Java 堆中的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。可以避免在 Java 堆和 Native 堆中來回的資料耗時操作。
OutOfMemoryError:會受到本機記憶體限制,如果記憶體區域總和大於實體記憶體限制從而導致動態擴充套件時出現該異常。

1.2 HotSpot 虛擬機器物件探祕

主要介紹資料是如何建立、如何佈局以及如何訪問的。

1.2.1 物件的建立

建立過程比較複雜,建議看書瞭解,這裡提供個人的總結。

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

類載入檢查通過之後,為新物件分配記憶體(記憶體大小在類載入完成後便可確認)。在堆的空閒記憶體中劃分一塊區域(‘指標碰撞-記憶體規整’或‘空閒列表-記憶體交錯’的分配方式)。

前面講的每個執行緒在堆中都會有私有的分配緩衝區(TLAB),這樣可以很大程度避免在併發情況下頻繁建立物件造成的執行緒不安全。

記憶體空間分配完成後會初始化為 0(不包括物件頭),接下來就是填充物件頭,把物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的 GC 分代年齡等資訊存入物件頭。

執行 new 指令後執行 init 方法後才算一份真正可用的物件建立完成。

1.2.2 物件的記憶體佈局

在 HotSpot 虛擬機器中,分為 3 塊區域:物件頭(Header)例項資料(Instance Data)對齊填充(Padding)

物件頭(Header):包含兩部分,第一部分用於儲存物件自身的執行時資料,如雜湊碼、GC 分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等,32 位虛擬機器佔 32 bit,64 位虛擬機器佔 64 bit。官方稱為 ‘Mark Word’。第二部分是型別指標,即物件指向它的類的後設資料指標,虛擬機器通過這個指標確定這個物件是哪個類的例項。另外,如果是 Java 陣列,物件頭中還必須有一塊用於記錄陣列長度的資料,因為普通物件可以通過 Java 物件後設資料確定大小,而陣列物件不可以。

例項資料(Instance Data):程式程式碼中所定義的各種型別的欄位內容(包含父類繼承下來的和子類中定義的)。

對齊填充(Padding):不是必然需要,主要是佔位,保證物件大小是某個位元組的整數倍。

1.2.3 物件的訪問定位

使用物件時,通過棧上的 reference 資料來操作堆上的具體物件。

通過控制程式碼訪問

Java 堆中會分配一塊記憶體作為控制程式碼池。reference 儲存的是控制程式碼地址。詳情見圖。

使用直接指標訪問

reference 中直接儲存物件地址

比較:使用控制程式碼的最大好處是 reference 中儲存的是穩定的控制程式碼地址,在物件移動(GC)是隻改變例項資料指標地址,reference 自身不需要修改。直接指標訪問的最大好處是速度快,節省了一次指標定位的時間開銷。如果是物件頻繁 GC 那麼控制程式碼方法好,如果是物件頻繁訪問則直接指標訪問好。

1.3 實戰

// 待填

2. 垃圾回收器與記憶體分配策略

2.1 概述

程式計數器、虛擬機器棧、本地方法棧 3 個區域隨執行緒生滅(因為是執行緒私有),棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。而 Java 堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期才知道那些物件會建立,這部分記憶體的分配和回收都是動態的,垃圾回收期所關注的就是這部分記憶體。

2.2 物件已死嗎?

在進行記憶體回收之前要做的事情就是判斷那些物件是‘死’的,哪些是‘活’的。

2.2.1 引用計數法

給物件新增一個引用計數器。但是難以解決迴圈引用問題。



從圖中可以看出,如果不下小心直接把 Obj1-reference 和 Obj2-reference 置 null。則在 Java 堆當中的兩塊記憶體依然保持著互相引用無法回收。

2.2.2 可達性分析法

通過一系列的 ‘GC Roots’ 的物件作為起始點,從這些節點出發所走過的路徑稱為引用鏈。當一個物件到 GC Roots 沒有任何引用鏈相連的時候說明物件不可用。

可作為 GC Roots 的物件:

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

2.2.3 再談引用

前面的兩種方式判斷存活時都與‘引用’有關。但是 JDK 1.2 之後,引用概念進行了擴充,下面具體介紹。

下面四種引用強度一次逐漸減弱

強引用

類似於 Object obj = new Object(); 建立的,只要強引用在就不回收。

軟引用

SoftReference 類實現軟引用。在系統要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行二次回收。

弱引用

WeakReference 類實現弱引用。物件只能生存到下一次垃圾收集之前。在垃圾收集器工作時,無論記憶體是否足夠都會回收掉只被弱引用關聯的物件。

虛引用

PhantomReference 類實現虛引用。無法通過虛引用獲取一個物件的例項,為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。

2.2.4 生存還是死亡

即使在可達性分析演算法中不可達的物件,也並非是“facebook”的,這時候它們暫時出於“緩刑”階段,一個物件的真正死亡至少要經歷兩次標記過程:如果物件在進行中可達性分析後發現沒有與 GC Roots 相連線的引用鏈,那他將會被第一次標記並且進行一次篩選,篩選條件是此物件是否有必要執行 finalize() 方法。當物件沒有覆蓋 finalize() 方法,或者 finalize() 方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

如果這個物件被判定為有必要執行 finalize() 方法,那麼這個物件竟會放置在一個叫做 F-Queue 的佇列中,並在稍後由一個由虛擬機器自動建立的、低優先順序的 Finalizer 執行緒去執行它。這裡所謂的“執行”是指虛擬機器會出發這個方法,並不承諾或等待他執行結束。finalize() 方法是物件逃脫死亡命運的最後一次機會,稍後 GC 將對 F-Queue 中的物件進行第二次小規模的標記,如果物件要在 finalize() 中成功拯救自己 —— 只要重新與引用鏈上的任何一個物件簡歷關聯即可。

finalize() 方法只會被系統自動呼叫一次。

2.2.5 回收方法區

在堆中,尤其是在新生代中,一次垃圾回收一般可以回收 70% ~ 95% 的空間,而永久代的垃圾收集效率遠低於此。

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

判斷廢棄常量:一般是判斷沒有該常量的引用。

判斷無用的類:要以下三個條件都滿足

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

2.3 垃圾回收演算法

僅提供思路

2.3.1 標記 —— 清除演算法

直接標記清除就可。

兩個不足:

  • 效率不高
  • 空間會產生大量碎片

2.3.2 複製演算法

把空間分成兩塊,每次只對其中一塊進行 GC。當這塊記憶體使用完時,就將還存活的物件複製到另一塊上面。

解決前一種方法的不足,但是會造成空間利用率低下。因為大多數新生代物件都不會熬過第一次 GC。所以沒必要 1 : 1 劃分空間。可以分一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 空間和其中一塊 Survivor。當回收時,將 Eden 和 Survivor 中還存活的物件一次性複製到另一塊 Survivor 上,最後清理 Eden 和 Survivor 空間。大小比例一般是 8 : 1 : 1,每次浪費 10% 的 Survivor 空間。但是這裡有一個問題就是如果存活的大於 10% 怎麼辦?這裡採用一種分配擔保策略:多出來的物件直接進入老年代。

2.3.3 標記-整理演算法

不同於針對新生代的複製演算法,針對老年代的特點,建立該演算法。主要是把存活物件移到記憶體的一端。

2.3.4 分代回收

根據存活物件劃分幾塊記憶體區,一般是分為新生代和老年代。然後根據各個年代的特點制定相應的回收演算法。

新生代

每次垃圾回收都有大量物件死去,只有少量存活,選用複製演算法比較合理。

老年代

老年代中物件存活率較高、沒有額外的空間分配對它進行擔保。所以必須使用 標記 —— 清除 或者 標記 —— 整理 演算法回收。

2.4 HotSpot 的演算法實現

// 待填

2.5 垃圾回收器

收集演算法是記憶體回收的理論,而垃圾回收器是記憶體回收的實踐。



說明:如果兩個收集器之間存在連線說明他們之間可以搭配使用。

2.5.1 Serial 收集器

這是一個單執行緒收集器。意味著它只會使用一個 CPU 或一條收集執行緒去完成收集工作,並且在進行垃圾回收時必須暫停其它所有的工作執行緒直到收集結束。

2.5.2 ParNew 收集器

可以認為是 Serial 收集器的多執行緒版本。

並行:Parallel

指多條垃圾收集執行緒並行工作,此時使用者執行緒處於等待狀態

併發:Concurrent

指使用者執行緒和垃圾回收執行緒同時執行(不一定是並行,有可能是交叉執行),使用者程式在執行,而垃圾回收執行緒在另一個 CPU 上執行。

2.5.3 Parallel Scavenge 收集器

這是一個新生代收集器,也是使用複製演算法實現,同時也是並行的多執行緒收集器。

CMS 等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒所停頓的時間,而 Parallel Scavenge 收集器的目的是達到一個可控制的吞吐量(Throughput = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間))。

作為一個吞吐量優先的收集器,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整停頓時間。這就是 GC 的自適應調整策略(GC Ergonomics)。

2.5.4 Serial Old 收集器

收集器的老年代版本,單執行緒,使用 標記 —— 整理

2.5.5 Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多執行緒,使用 標記 —— 整理

2.5.6 CMS 收集器

CMS (Concurrent Mark Sweep) 收集器是一種以獲取最短回收停頓時間為目標的收集器。基於 標記 —— 清除 演算法實現。

運作步驟:

  1. 初始標記(CMS initial mark):標記 GC Roots 能直接關聯到的物件
  2. 併發標記(CMS concurrent mark):進行 GC Roots Tracing
  3. 重新標記(CMS remark):修正併發標記期間的變動部分
  4. 併發清除(CMS concurrent sweep)

缺點:對 CPU 資源敏感、無法收集浮動垃圾、標記 —— 清除 演算法帶來的空間碎片

2.5.7 G1 收集器

面向服務端的垃圾回收器。

優點:並行與併發、分代收集、空間整合、可預測停頓。

運作步驟:

  1. 初始標記(Initial Marking)
  2. 併發標記(Concurrent Marking)
  3. 最終標記(Final Marking)
  4. 篩選回收(Live Data Counting and Evacuation)

2.6 記憶體分配與回收策略

2.6.1 物件優先在 Eden 分配

物件主要分配在新生代的 Eden 區上,如果啟動了本地執行緒分配緩衝區,將執行緒優先在 (TLAB) 上分配。少數情況會直接分配在老年代中。

一般來說 Java 堆的記憶體模型如下圖所示:

新生代 GC (Minor GC)

發生在新生代的垃圾回收動作,頻繁,速度快。

老年代 GC (Major GC / Full GC)

發生在老年代的垃圾回收動作,出現了 Major GC 經常會伴隨至少一次 Minor GC(非絕對)。Major GC 的速度一般會比 Minor GC 慢十倍以上。

2.6.2 大物件直接進入老年代

2.6.3 長期存活的物件將進入老年代

2.6.4 動態物件年齡判定

2.6.5 空間分配擔保

3. Java 記憶體模型與執行緒

3.1 Java 記憶體模型

遮蔽掉各種硬體和作業系統的記憶體訪問差異。

3.1.1 主記憶體和工作記憶體之間的互動

操作 作用物件 解釋
lock 主記憶體 把一個變數標識為一條執行緒獨佔的狀態
unlock 主記憶體 把一個處於鎖定狀態的變數釋放出來,釋放後才可被其他執行緒鎖定
read 主記憶體 把一個變數的值從主記憶體傳輸到執行緒工作記憶體中,以便 load 操作使用
load 工作記憶體 把 read 操作從主記憶體中得到的變數值放入工作記憶體中
use 工作記憶體 把工作記憶體中一個變數的值傳遞給執行引擎,
每當虛擬機器遇到一個需要使用到變數值的位元組碼指令時將會執行這個操作
assign 工作記憶體 把一個從執行引擎接收到的值賦接收到的值賦給工作記憶體的變數,
每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作
store 工作記憶體 把工作記憶體中的一個變數的值傳送到主記憶體中,以便 write 操作
write 工作記憶體 把 store 操作從工作記憶體中得到的變數的值放入主記憶體的變數中

3.1.2 對於 volatile 型變數的特殊規則

關鍵字 volatile 是 Java 虛擬機器提供的最輕量級的同步機制。

一個變數被定義為 volatile 的特性:

  1. 保證此變數對所有執行緒的可見性。但是操作並非原子操作,併發情況下不安全。

如果不符合 運算結果並不依賴變數當前值,或者能夠確保只有單一的執行緒修改變數的值變數不需要與其他的狀態變數共同參與不變約束 就要通過加鎖(使用 synchronize 或 java.util.concurrent 中的原子類)來保證原子性。

  1. 禁止指令重排序優化。

通過插入記憶體屏障保證一致性。

3.1.3 對於 long 和 double 型變數的特殊規則

Java 要求對於主記憶體和工作記憶體之間的八個操作都是原子性的,但是對於 64 位的資料型別,有一條寬鬆的規定:允許虛擬機器將沒有被 volatile 修飾的 64 位資料的讀寫操作劃分為兩次 32 位的操作來進行,即允許虛擬機器實現選擇可以不保證 64 位資料型別的 load、store、read 和 write 這 4 個操作的原子性。這就是 long 和 double 的非原子性協定。

3.1.4 原子性、可見性與有序性

回顧下併發下應該注意操作的那些特性是什麼,同時加深理解。

  • 原子性(Atomicity)

由 Java 記憶體模型來直接保證的原子性變數操作包括 read、load、assign、use、store 和 write。大致可以認為基本資料型別的操作是原子性的。同時 lock 和 unlock 可以保證更大範圍操作的原子性。而 synchronize 同步塊操作的原子性是用更高層次的位元組碼指令 monitorenter 和 monitorexit 來隱式操作的。

  • 可見性(Visibility)

是指當一個執行緒修改了共享變數的值,其他執行緒也能夠立即得知這個通知。主要操作細節就是修改值後將值同步至主記憶體(volatile 值使用前都會從主記憶體重新整理),除了 volatile 還有 synchronize 和 final 可以保證可見性。同步塊的可見性是由“對一個變數執行 unlock 操作之前,必須先把此變數同步會主記憶體中( store、write 操作)”這條規則獲得。而 final 可見性是指:被 final 修飾的欄位在構造器中一旦完成,並且構造器沒有把 “this” 的引用傳遞出去( this 引用逃逸是一件很危險的事情,其他執行緒有可能通過這個引用訪問到“初始化了一半”的物件),那在其他執行緒中就能看見 final 欄位的值。

  • 有序性(Ordering)

如果在被執行緒內觀察,所有操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有操作都是無序的。前半句指“執行緒內表現為序列的語義”,後半句是指“指令重排”現象和“工作記憶體與主記憶體同步延遲”現象。Java 語言通過 volatile 和 synchronize 兩個關鍵字來保證執行緒之間操作的有序性。volatile 自身就禁止指令重排,而 synchronize 則是由“一個變數在同一時刻指允許一條執行緒對其進行 lock 操作”這條規則獲得,這條規則決定了持有同一個鎖的兩個同步塊只能序列的進入。

3.1.5 先行發生原則

也就是 happens-before 原則。這個原則是判斷資料是否存在競爭、執行緒是否安全的主要依據。先行發生是 Java 記憶體模型中定義的兩項操作之間的偏序關係。

天然的先行發生關係

規則 解釋
程式次序規則 在一個執行緒內,程式碼按照書寫的控制流順序執行
管程鎖定規則 一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作
volatile 變數規則 volatile 變數的寫操作先行發生於後面對這個變數的讀操作
執行緒啟動規則 Thread 物件的 start() 方法先行發生於此執行緒的每一個動作
執行緒終止規則 執行緒中所有的操作都先行發生於對此執行緒的終止檢測
(通過 Thread.join() 方法結束、 Thread.isAlive() 的返回值檢測)
執行緒中斷規則 對執行緒 interrupt() 方法呼叫優先發生於被中斷執行緒的程式碼檢測到中斷事件的發生
(通過 Thread.interrupted() 方法檢測)
物件終結規則 一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize() 方法的開始
傳遞性 如果操作 A 先於 操作 B 發生,操作 B 先於 操作 C 發生,那麼操作 A 先於 操作 C

3.2 Java 與執行緒

3.2.1 執行緒的實現

使用核心執行緒實現

直接由作業系統核心支援的執行緒,這種執行緒由核心完成切換。程式一般不會直接去使用核心執行緒,而是去使用核心執行緒的一種高階介面 —— 輕量級程式(LWP),輕量級程式就是我們通常意義上所講的執行緒,每個輕量級程式都有一個核心級執行緒支援。

使用使用者執行緒實現

廣義上來說,只要不是核心執行緒就可以認為是使用者執行緒,因此可以認為輕量級程式也屬於使用者執行緒。狹義上說是完全建立在使用者空間的執行緒庫上的並且核心系統不可感知的。

使用使用者執行緒夾加輕量級程式混合實現

直接看圖

Java 執行緒實現

平臺不同實現方式不同,可以認為是一條 Java 執行緒對映到一條輕量級程式。

3.2.2 Java 執行緒排程

協同式執行緒排程

執行緒執行時間由執行緒自身控制,實現簡單,切換執行緒自己可知,所以基本沒有執行緒同步問題。壞處是執行時間不可控,容易阻塞。

搶佔式執行緒排程

每個執行緒由系統來分配執行時間。

3.2.3 狀態轉換

五種狀態:

  • 新建(new)

建立後尚未啟動的執行緒。

  • 執行(Runable)

Runable 包括了作業系統執行緒狀態中的 Running 和 Ready,也就是出於此狀態的執行緒有可能正在執行,也有可能正在等待 CPU 為他分配時間。

  • 無限期等待(Waiting)

出於這種狀態的執行緒不會被 CPU 分配時間,它們要等其他執行緒顯示的喚醒。

以下方法會然執行緒進入無限期等待狀態:
1.沒有設定 Timeout 引數的 Object.wait() 方法。
2.沒有設定 Timeout 引數的 Thread.join() 方法。
3.LookSupport.park() 方法。

  • 限期等待(Timed Waiting)

處於這種狀態的執行緒也不會分配時間,不過無需等待配其他執行緒顯示地喚醒,在一定時間後他們會由系統自動喚醒。

以下方法會讓執行緒進入限期等待狀態:
1.Thread.sleep() 方法。
2.設定了 Timeout 引數的 Object.wait() 方法。
3.設定了 Timeout 引數的 Thread.join() 方法。
4.LockSupport.parkNanos() 方法。
5.LockSupport.parkUntil() 方法。

  • 阻塞(Blocked)

執行緒被阻塞了,“阻塞狀態”和“等待狀態”的區別是:“阻塞狀態”在等待著獲取一個排他鎖,這個時間將在另外一個執行緒放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。

  • 結束(Terminated)

已終止執行緒的執行緒狀態。

4. 執行緒安全與鎖優化

// 待填

5. 類檔案結構

// 待填

有點懶了。。。先貼幾個網址吧。

1. Official:The class File Format
2.亦山: 《Java虛擬機器原理圖解》 1.1、class檔案基本組織結構

6. 虛擬機器類載入機制

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

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

6.1 類載入時機

類的生命週期( 7 個階段)

其中載入、驗證、準備、初始化和解除安裝這五個階段的順序是確定的。解析階段可以在初始化之後再開始(執行時繫結或動態繫結或晚期繫結)。

以下五種情況必須對類進行初始化(而載入、驗證、準備自然需要在此之前完成):

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

前面的五種方式是對一個類的主動引用,除此之外,所有引用類的方法都不會觸發初始化,佳作被動引用。舉幾個例子~

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 1127;
}

public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world!"
}

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
        /**
         *  output : SuperClass init!
         * 
         * 通過子類引用父類的靜態物件不會導致子類的初始化
         * 只有直接定義這個欄位的類才會被初始化
         */

        SuperClass[] sca = new SuperClass[10];
        /**
         *  output : 
         * 
         * 通過陣列定義來引用類不會觸發此類的初始化
         * 虛擬機器在執行時動態建立了一個陣列類
         */

        System.out.println(ConstClass.HELLOWORLD);
        /**
         *  output : 
         * 
         * 常量在編譯階段會存入呼叫類的常量池當中,本質上並沒有直接引用到定義類常量的類,
         * 因此不會觸發定義常量的類的初始化。
         * “hello world” 在編譯期常量傳播優化時已經儲存到 NotInitialization 常量池中了。
         */
    }
}複製程式碼

6.2 類的載入過程

6.2.1 載入
  1. 通過一個類的全限定名來獲取定義次類的二進位制流(ZIP 包、網路、運算生成、JSP 生成、資料庫讀取)。
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
  3. 在記憶體中生成一個代表這個類的 java.lang.Class 物件,作為方法去這個類的各種資料的訪問入口。

陣列類的特殊性:陣列類本身不通過類載入器建立,它是由 Java 虛擬機器直接建立的。但陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別最終是要靠類載入器去建立的,陣列建立過程如下:

  1. 如果陣列的元件型別是引用型別,那就遞迴採用類載入載入。
  2. 如果陣列的元件型別不是引用型別,Java 虛擬機器會把陣列標記為引導類載入器關聯。
  3. 陣列類的可見性與他的元件型別的可見性一致,如果元件型別不是引用型別,那陣列類的可見性將預設為 public。

記憶體中例項的 java.lang.Class 物件存在方法區中。作為程式訪問方法區中這些型別資料的外部介面。
載入階段與連線階段的部分內容是交叉進行的,但是開始時間保持先後順序。

6.2.2 驗證

是連線的第一步,確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器要求。

檔案格式驗證
  1. 是否以魔數 0xCAFEBABE 開頭
  2. 主、次版本號是否在當前虛擬機器處理範圍之內
  3. 常量池的常量是否有不被支援常量的型別(檢查常量 tag 標誌)
  4. 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量
  5. CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的資料
  6. Class 檔案中各個部分集檔案本身是否有被刪除的附加的其他資訊
  7. ……

只有通過這個階段的驗證後,位元組流才會進入記憶體的方法區進行儲存,所以後面 3 個驗證階段全部是基於方法區的儲存結構進行的,不再直接操作位元組流。

後設資料驗證
  1. 這個類是否有父類(除 java.lang.Object 之外)
  2. 這個類的父類是否繼承了不允許被繼承的類(final 修飾的類)
  3. 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法
  4. 類中的欄位、方法是否與父類產生矛盾(覆蓋父類 final 欄位、出現不符合規範的過載)

這一階段主要是對類的後設資料資訊進行語義校驗,保證不存在不符合 Java 語言規範的後設資料資訊。

位元組碼驗證
  1. 保證任意時刻運算元棧的資料型別與指令程式碼序列都鞥配合工作(不會出現按照 long 型別讀一個 int 型資料)
  2. 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上
  3. 保證方法體中的型別轉換是有效的(子類物件賦值給父類資料型別是安全的,反過來不合法的)
  4. ……

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

符號引用驗證
  1. 符號引用中通過字元創描述的全限定名是否能找到對應的類
  2. 在指定類中是否存在符方法的欄位描述符以及簡單名稱所描述的方法和欄位
  3. 符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問
  4. ……

最後一個階段的校驗發生在迅疾將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,還有以上提及的內容。
符號引用的目的是確保解析動作能正常執行,如果無法通過符號引用驗證將丟擲一個 java.lang.IncompatibleClass.ChangeError 異常的子類。如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。

6.2.3 準備

這個階段正式為類分配記憶體並設定類變數初始值,記憶體在方法去中分配(含 static 修飾的變數不含例項變數)。

public static int value = 1127;
這句程式碼在初始值設定之後為 0,因為這時候尚未開始執行任何 Java 方法。而把 value 賦值為 1127 的 putstatic 指令是程式被編譯後,存放於 clinit() 方法中,所以初始化階段才會對 value 進行賦值。

基本資料型別的零值

資料型別 零值 資料型別 零值
int 0 boolean false
long 0L float 0.0f
short (short) 0 double 0.0d
char '\u0000' reference null
byte (byte) 0

特殊情況:如果類欄位的欄位屬性表中存在 ConstantValue 屬性,在準備階段虛擬機器就會根據 ConstantValue 的設定將 value 賦值為 1127。

6.2.4 解析

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

  1. 符號引用
    符號引用以一組符號來描述所引用的目標,符號可以使任何形式的字面量。
  2. 直接引用
    直接引用可以使直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用和迅疾的記憶體佈局實現有關

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

6.2.5 初始化

前面過程都是以虛擬機器主導,而初始化階段開始執行類中的 Java 程式碼。

6.3 類載入器

通過一個類的全限定名來獲取描述此類的二進位制位元組流。

6.3.1 雙親委派模型

從 Java 虛擬機器角度講,只存在兩種類載入器:一種是啟動類載入器(C++ 實現,是虛擬機器的一部分);另一種是其他所有類的載入器(Java 實現,獨立於虛擬機器外部且全繼承自 java.lang.ClassLoader)

  1. 啟動類載入器
    載入 lib 下或被 -Xbootclasspath 路徑下的類

  2. 擴充套件類載入器
    載入 lib/ext 或者被 java.ext.dirs 系統變數所指定的路徑下的類

  3. 引用程式類載入器
    ClassLoader負責,載入使用者路徑上所指定的類庫。


除頂層啟動類載入器之外,其他都有自己的父類載入器。
工作過程:如果一個類載入器收到一個類載入的請求,它首先不會自己載入,而是把這個請求委派給父類載入器。只有父類無法完成時子類才會嘗試載入。

6.3.2 破壞雙親委派模型

keyword:執行緒上下文載入器(Thread Context ClassLoader)

最後

前面兩次粗略的閱讀,能理解內容,但是很難記住細節。每每碰到不會的知識點就上網查,所以知識點太碎片腦子裡沒有體系不僅更不容易記住,而且更加容易混亂。但是通過這種方式記錄發現自己清晰了很多,就算以後忘記,知識再次撿起的成本也低了很多。

這次還有一些章節雖然閱讀了,但是還未完成記錄。等自己理解深刻有空閒了就再次記錄下來,這裡的內容均出自周志明老師的《深入理解 Java 虛擬機器》,有興趣的可以入手紙質版。

多謝閱讀

相關文章