重讀 Java虛擬機(jvm)

1. Java 內存區域與內存溢出異常

1.1 運行時數據區域

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

重讀 Java虛擬機(jvm)

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 方法區

屬於共享內存區域,存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

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

重讀 Java虛擬機(jvm)

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 存儲的是句柄地址。詳情見圖。

重讀 Java虛擬機(jvm)

使用直接指針訪問

reference 中直接存儲對象地址

重讀 Java虛擬機(jvm)

比較:使用句柄的最大好處是 reference 中存儲的是穩定的句柄地址,在對象移動(GC)是隻改變實例數據指針地址,reference 自身不需要修改。直接指針訪問的最大好處是速度快,節省了一次指針定位的時間開銷。如果是對象頻繁 GC 那麼句柄方法好,如果是對象頻繁訪問則直接指針訪問好。

1.3 實戰

// 待填

2. 垃圾回收器與內存分配策略

2.1 概述

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

2.2 對象已死嗎?

在進行內存回收之前要做的事情就是判斷那些對象是‘死’的,哪些是‘活’的。

2.2.1 引用計數法

給對象添加一個引用計數器。但是難以解決循環引用問題。

重讀 Java虛擬機(jvm)

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

2.2.2 可達性分析法

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

重讀 Java虛擬機(jvm)

可作為 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 垃圾回收器

收集算法是內存回收的理論,而垃圾回收器是內存回收的實踐。

重讀 Java虛擬機(jvm)

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

2.5.1 Serial 收集器

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

重讀 Java虛擬機(jvm)

2.5.2 ParNew 收集器

可以認為是 Serial 收集器的多線程版本。

重讀 Java虛擬機(jvm)

並行:Parallel

指多條垃圾收集線程並行工作,此時用戶線程處於等待狀態

併發:Concurrent

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

2.5.3 Parallel Scavenge 收集器

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

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

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

2.5.4 Serial Old 收集器

收集器的老年代版本,單線程,使用 標記 —— 整理

重讀 Java虛擬機(jvm)

2.5.5 Parallel Old 收集器

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

重讀 Java虛擬機(jvm)

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)

重讀 Java虛擬機(jvm)

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

2.5.7 G1 收集器

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

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

運作步驟:

  1. 初始標記(Initial Marking)

  2. 併發標記(Concurrent Marking)

  3. 最終標記(Final Marking)

  4. 篩選回收(Live Data Counting and Evacuation)

重讀 Java虛擬機(jvm)

2.6 內存分配與回收策略

2.6.1 對象優先在 Eden 分配

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

一般來說 Java 堆的內存模型如下圖所示:

重讀 Java虛擬機(jvm)

新生代 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 內存模型與線程

重讀 Java虛擬機(jvm)

3.1 Java 內存模型

屏蔽掉各種硬件和操作系統的內存訪問差異。

重讀 Java虛擬機(jvm)

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虛擬機(jvm)

使用用戶線程實現

廣義上來說,只要不是內核線程就可以認為是用戶線程,因此可以認為輕量級進程也屬於用戶線程。狹義上說是完全建立在用戶空間的線程庫上的並且內核系統不可感知的。

重讀 Java虛擬機(jvm)

使用用戶線程夾加輕量級進程混合實現

直接看圖

重讀 Java虛擬機(jvm)

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)

已終止線程的線程狀態。

重讀 Java虛擬機(jvm)

4. 線程安全與鎖優化

// 待填

5. 類文件結構

// 待填

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

1. Official:The class File Format

2.亦山: 《Java虛擬機原理圖解》 1.1、class文件基本組織結構

6. 虛擬機類加載機制

虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、裝換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型。

在 Java 語言中,類型的加載、連接和初始化過程都是在程序運行期間完成的。

6.1 類加載時機

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

重讀 Java虛擬機(jvm)

其中加載、驗證、準備、初始化和卸載這五個階段的順序是確定的。解析階段可以在初始化之後再開始(運行時綁定或動態綁定或晚期綁定)。

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

  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 進行賦值。

基本數據類型的零值

數據類型零值數據類型零值
int0booleanfalse
long0Lfloat0.0f
short(short) 0double0.0d
char'

相關推薦

推薦中...