前言
JVM是Java的重要組成部分,對于我這個Cpper轉Javaer也需要認真學習才對。
一、JVM內存結構
JDK 1.8 同 JDK 1.7 比,最大的差別就是:元數據區取代了永久代。元空間的本質和永久代類似,都是對 JVM 規范中方法區的實現。不過元空間與永久代之間最大的區別在于:元數據空間并不在虛擬機中,而是使用本地內存。
1、程序計數器(PC寄存器)
程序計數器:一塊較小的內存空間,是當前線程正在執行的那條字節碼指令的地址。若當前線程正在執行的是一個本地方法,那么此時程序計數器為Undefined
。
程序計數器的作用:
- 字節碼解釋器通過改變程序計數器來依次讀取指令,從而實現代碼的流程控制。
- 在多線程情況下,程序計數器記錄的是當前線程執行的位置,從而當線程切換回來時,就知道上次線程執行到哪了。
程序計數器的特點:
- 是一塊較小的內存空間。
- 線程私有,每條線程都有自己的程序計數器。
- 生命周期:隨著線程的創建而創建,隨著線程的結束而銷毀。
- 是唯一一個不會出現
OutOfMemoryError
的內存區域。
2、Java虛擬機棧
Java虛擬機棧:Java方法運行過程中的內存模型。
Java虛擬機棧會為每一個即將運行的Java方法創建一塊叫做“棧幀
”的區域,用于存放該方法運行過程中的一些信息,如:
- 局部變量表
- 操作數棧
- 動態鏈接
- 方法返回地址
棧空間雖然不是無限的,但一般正常調用的情況下是不會出現問題的。不過,如果函數調用陷入無限循環的話,就會導致棧中被壓入太多棧幀而占用太多空間,導致棧空間過深。那么當線程請求棧的深度超過當前 Java 虛擬機棧的最大深度的時候,就拋出 StackOverFlowError
錯誤。
棧幀隨著方法調用而創建,隨著方法結束而銷毀。無論方法正常完成還是異常完成都算作方法結束。
虛擬機棧可能出現的兩種錯誤:
StackOverFlowError
OutOfMemoryError
3、本地方法棧(C棧)
本地方法棧則為虛擬機使用native方法服務。 在HotSpot虛擬機中和Java虛擬機合二為一。
棧幀變化過程:
本地方法被執行時,在本地方法棧也會創建一塊棧幀,用于存放該方法的局部變量、操作數棧、動態鏈接、方法出口信息等。
方法執行結束后,相應的棧幀也會出棧,并釋放內存空間。也會拋出StackOverFlowError和OutOfMemoryError異常。
如果Java虛擬機本身不支持Natvie方法,或是本身不依賴于傳統棧,那么也不提供本地方法棧。如果支持本地方法棧,那么這個棧一般會在線程創建的時候按線程分配。
4、堆(認真復習,這一塊好復雜)
此內存區域的唯一目的就是存放對象實例,幾乎
所有的對象實例以及數組都在這里分配內存。
從 JDK 1.7 開始已經默認開啟逃逸分析,如果某些方法中的對象引用沒有被返回或者未被外面使用(也就是未逃逸出去),那么對象可以直接在棧上分配內存。
堆的特點
- 線程共享,整個Java虛擬機只有一個堆,所有的線程都訪問同一個堆。
- 在虛擬機啟動時創建。
- 是垃圾回收的主要場所。
- 堆可分為新生代(Eden區:From Survior,To Survior)、老年代。
- Java虛擬機規范規定,堆可以處于物理上不連續的內存空間,但在邏輯上它應該被視為連續的。
- 關于Surviror s0,s1區:復制之后有交換,誰空誰是to。
不同的區域存放不同的生命周期的對象,這樣可以根據不同的區域使用不同的垃圾回收算法,更具有針對性。
堆的大小既可以固定地也可以擴展,但對于主流的虛擬機,堆的大小是可擴展的,因為當線程請求分配內存,單堆已滿,且內存已無法再擴展時,就拋出OutOfMemoryError異常。
新生代與老年代
- 老年代比新生代生命周期長。、
- 新生代與老年代空間默認比例1:2:JVM調參數,XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整個堆的1/3。
- HotSpot中,Eden空間和另外兩個Survivor空間缺省所占的比例是8:1:1。
- 幾乎所有的Java對象都是在Eden區被new出來的,Eden放不下的大對象,就直接進入老年代了。
對象分配過程
- new的對象先放在Eden區,大小有限制;
- 如果創建新對象時,Eden空間填滿了,就會觸發Minor GC,將Eden不再被其他對象引用的對象進行銷毀,再加載新的對象放到Eden區,特別注意的是Survivor區滿了是不是觸發Minor GC的,而是Eden空間填滿了,Minor GC才順便清理Survivor區,將Eden中剩余的對象移到Survivor0區
- 再次觸發垃圾回收,此時上次Survivor下來的,放在Survivor0區的,如果沒有回收,就會放到Survivor1區
- 再次經歷垃圾回收,又會將幸存者重新放回Survivor0區,依次類推
- 默認是15次的循環,超過15次,則會將幸存者區幸存下來的轉去老年區,jvm參數設置次數:-XX:MaxTenuringThreshold=N進行設置
- 頻繁在新生區收集,很少在老年區收集,幾乎不在永久區/元空間收集
Full GC/Major GC觸發條件
目前已出現的三種GC方式:Major GC、Minor GC、Full GC
- 顯示調用System.gc(),老年代的空間不夠,方法區的空間不夠等都會觸發Full GC,同時對新生代和老年代回收,Full GC的STW的時間最長,應該要避免
- 在出現Major GC之前,回顯觸發Minor GC,如果老年代的空間還是不夠就會觸發Major GC,STW的時間長度Minor GC
逃逸分析
標量替換
- 標量不可在分解的量,java 的基本數據類型就是標量,標量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在 JAVA 中對象就是可以被進一步分解的聚合量
- 替換過程,通過逃逸分析確定該對象不會被外部訪問,并且對象可以被進一步分解時,JVM 不會創建該對象,而會將該對象成員變量分解若干個被這個方法使用的成員變量所代替。這些代替的成員變量在棧幀或寄存器上分配空間。
- 使用逃逸分析,編譯器可以對代碼做如下優化:
- 同步省略:如果一個對象被發現只能從一個線程被訪問到,那么對于這個對象的操作可以不考慮同步。
- 將堆分配轉化為棧分配:如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配。
- 分離對象或標量替換:有的對象可能不需要作為一個連續的內存結構存在也可以被訪問到,那么對象的部分(或全部)可以不存儲在內存,而是存儲在 CPU 寄存器中。
TLAB
TLAB的全稱是Thread Local Allocation Buffer,即線程本地分配緩存去,是屬于Eden區的,這是一個線程專用的內存分配區域,線程私有,默認開啟的(當然也不是絕對的,也要看哪種類型的虛擬機)
當然并不是所有的對象都可以在 TLAB 中分配內存成功,如果失敗了就會使用加鎖的機制來保持操作的原子性
-XX:+UseTLAB使用 TLAB,-XX:+TLABSize 設置 TLAB 大小
5、方法區
Java 虛擬機規范中定義方法區是堆的一個邏輯部分。
方法區的特點
- 線程共享。方法區是堆的一個邏輯部分,因此和堆一樣,都是線程共享的。整個虛擬機中只有一個方法區。
- 永久代。方法區中的信息一般需要長期存在,而且它又是堆的邏輯分區,因此用堆的劃分方法,把方法區稱為"永久代"。
- 內存回收效率低。方法區中的信息一般需要長期存在,回收一遍之后可能只有少量信息無效。主要回收目標是:對常量池的回收;對類型的卸載。
- Java虛擬機規范對方法區的要求比較寬松。和堆一樣,允許固定大小,也允許動態擴展,還允許不實現垃圾回收。
運行時常量池
常量就存放在運行時常量池內。
當類被Java虛擬機加載后,.class
文件中的常量就存放在方法區的運行時常量池中。而且在運行期間,可以向常量池中添加新的常量。如String類的intern()方法就能在運行期間向常量池中添加字符串常量。
6、直接內存(堆外內存)
操作直接內存
在NIO中引入了一種基于通道和緩沖的IO方式。它可以通過調用本地方法直接分配Java虛擬機之外的內存,然后通過一個存儲在堆中的DirectByteBuffer對象直接操作該內存,而無須先將外部內存中的數據復制到堆中再進行操作,從而提高了數據操作的效率。
直接內存的大小不受Java虛擬機控制,但既然是內存,當內存不足時就會拋出OutOfMemoryError異常。
直接內存與堆內存比較
- 直接內存申請控件耗費更多的性能
- 直接內存讀取IO的性能要優化普通的堆內存
- 直接內存作用鏈:本地IO -> 直接內存 -> 本地IO
- 堆內存作用鏈: 本地IO -> 直接內存 -> 非直接內存 -> 直接內存 -> 本地IO
二、HotSpot 虛擬機對象探秘
對象的內存布局
對象頭可能包含類型指針,通過該指針能確定對象屬于哪個類。如果對象是一個數組,那么對象頭還會包括數組長度。
實例數據部分就是成員變量的值,其中包括父類成員變量和本類成員變量。
對齊填充用于確保對象的總長度為 8 字節的整數倍。
對象的創建過程
類加載檢查:
虛擬機在解析.class文件時,若遇到一條 new 指令,首先它會去檢查常量池中是否有這個類的符號引用,并且檢查這個符號引用所代表的類是否已被加載、解析和初始化過。如果沒有,那么必須先執行相應的類加載過程。
為新生對象分配內存,分配堆中內存有兩種方式:
- 指針碰撞
- 空閑列表
初始化:
分配完內存后,為對象中的成員變量賦上初始值,設置對象頭信息,
對象的訪問方式
所有對象的存儲控件都是在堆中分配的,但是這個對象的引用卻是在堆棧中分配的。
也就是說在建立一個對象時需要兩個地方都分配內存,在堆中分配的內存實際建立這個對象,而在堆棧中分配的內存知識一個指向這個堆對象的指針(引用)而已。
句柄訪問方式:
直接指針訪問方式:
三、垃圾收集策略與算法
垃圾收集主要是針對堆
和方法區
進行;程序計數器、虛擬機棧和本地方法棧這三個區域屬于線程私有的,只存在于線程的生命周期內,線程結束之后也會消失,因此不需要對這三個區域進行垃圾回收。
判斷對象是否存活
若一個對象不被任何對象或變量引用,那么它就是無效對象,需要被回收。這個地方的實現類似于C++的指針指針,感興趣的同學可以去對比下。
可達性分析法
所有和 GC Roots 直接或間接關聯的對象都是有效對象,和 GC Roots 沒有關聯的對象就是無效對象。
GC Roots 是指:
- Java 虛擬機棧(棧幀中的本地變量表)中引用的對象
- 本地方法棧中引用的對象
- 方法區中常量引用的對象
- 方法區中類靜態屬性引用的對象
GC Roots 并不包括堆中對象所引用的對象,這樣就不會有循環引用的問題。
引用的種類
回收堆中無效對象
對于可達分析中不可達的對象,也并不是沒有存活的可能。
判定finalize()是否有必要執行
JVM會判斷此對象是否有必要執行finalize()方法,如果對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,那么視為"沒有必要執行"。那么對象基本上就針對被回收了。
如果對象被判定為有必要執行finalize()方法,那么對象會被放入一個F-Queue隊列中,虛擬機會以較低的優先級執行這些finalize()方法,但不會確保所有的finalize()方法都會執行結束,如果finalize()方法出現耗時操作,虛擬機就直接停止指向該方法,將對象清除。
對象重生或死亡
如果在執行finalize()方法時,將this賦給了某一個引用,那么該對象就重生了。如果沒有,那么就會被垃圾收集器清除。
任何一個對象的finalize()方法只會被系統自動調用一次,如果對象面臨下一次回收,它的finalize()方法不會被再次執行,想繼續在finalize()中自救就失效了。
回收方法區內存
方法區中存放生命周期較長的類信息、常量、靜態變量,每次垃圾收集只有少量的垃圾被清除。
方法區中主要清除兩種垃圾:
- 廢棄常量:只要常量池中的常量不被任何變量或對象引用,那么這些常量就會被清除掉。
- 無用的類:
- 該類的所有對象都已經被清除
- 加載該類的ClassLoader已經被回收
- 該類的java.lang.Class對象沒有在任何地方被引用,無法再任何地方通過反射訪問該類的方法
一個類被虛擬機加載進方法區,那么在堆中就會有一個代表該類的對象:java.lang.Class。這個對象在類被加載進方法區時創建,在方法區該類被刪除時清除。
垃圾收集算法
學習了如何判定無效對象、無用類、廢棄常量之后,剩余工作就是回收這些垃圾。
常見的垃圾算法有以下幾個:
標記-清除算法
標記的過程是:遍歷所有的 GC Roots,然后將所有 GC Roots 可達的對象標記為存活的對象。
清除的過程將遍歷堆中所有的對象,將沒有標記的對象全部清除掉。與此同時,清除哪些被標記過對象的標記,以便下次的垃圾回收。
缺點:
- 效率問題:標記和清除兩個過程的效率都不高;
- 空間問題:標記清除之后會產生大量不連續的內存碎片,碎片太多可能導致以后需要分配較大對象時,無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。
復制算法(新生代)
為了解決效率問題,“復制”收集算法出現了。它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊內存用完,需要進行垃圾收集時,就將存活者的對象復制到另一塊上面,然后將第一塊內存全部清除。這種算法有優有劣:
- 優點:不會有內存碎片問題
- 缺點:內存縮小為原來的一半,浪費空間
為了解決空間利用率問題,可以將內存分為三塊:Eden、From Survivor、To Survivor,比例是8:1:1,每次使用Eden和其中一塊Survivor。回收時,將Eden和Survivor中還存活的對象一次性復制到另外一塊Survivor空間上,最后清理掉Eden和剛才使用的Survivor空間。這樣只有10%的內存被浪費。
但是我們無法保證每次回收都只有不多于10%的對象存活,當Survivor空間不夠,需要依賴其他內存(指老年代)進行分配擔保。
分配擔保
為對象分配內存空間時,如果Eden+Survivor中空閑區域無法裝下該對象,會觸發MinorGC進行垃圾收集。但如果Minor GC過后依然有超過10%的對象存活,這樣存活的對象直接通過分配擔保機制進入老年代,然后再講新對象存入Eden區。
標記-整理算法(老年代)
標記:它的第一個階段與標記-清除算法是一模一樣的,均是GC Roots,然后將存活的對象標記。
整理:移動所有存活的對象,且按照內存地址次序依次排列,然后將末端內存地址以后的內存全部回收。因此,第二階段才成為整理階段。
這是一種老年代的垃圾收集算法。老年代的對象一般壽命比較長,因此每次垃圾回收會有大量對象存活,如果采用復制算法,每次需要復制存活的對象,效率很低。
分代收集算法
根據對象存活周期的不同,將內存劃分為幾塊。一般是把Java堆分為新生代和老年代,針對各個年代的特點采用最適當的收集算法。
- 新生代:復制算法
- 老年代:標記-清除算法、標記-整理算法
四、HotSpot垃圾收集器
HotSpot 虛擬機提供了多種垃圾收集器,每種收集器都有各自的特點:
新生代垃圾收集器
Serial 垃圾收集器(單線程)
只開啟一條 GC 線程進行垃圾回收,并且在垃圾收集過程中停止一切用戶線程,即 Stop The World。Serial 垃圾收集器適合客戶端使用。
ParNew 垃圾收集器(多線程)
ParNew 是 Serial 的多線程版本。由多條 GC 線程并行地進行垃圾清理。但清理過程依然需要 Stop The World。
ParNew 追求“低停頓時間”,與 Serial 唯一區別就是使用了多線程進行垃圾收集,在多 CPU 環境下性能比 Serial 會有一定程度的提升;但線程切換需要額外的開銷,因此在單 CPU 環境中表現不如 Serial。
Parallel Scavenge 垃圾收集器(多線程)
Parallel Scavenge 和 ParNew 一樣,都是多線程、新生代垃圾收集器。但是兩者有巨大的不同點:
- Parallel Scavenge:追求CPU吞吐量,能夠在較短時間內完成指定任務,因此適合沒有交互的后臺計算。
- ParNew:追求降低用戶停頓時間,適合交互式應用。
吞吐量 = 運行用戶代碼時間 +(運行用戶代碼時間 + 垃圾收集時間)
追求高吞吐量,可通過減少GC執行實際過程的時間,然而,僅僅偶爾運行GC意味著每當GC運行時將有許多工作要做,因為在此期間積累了堆中的對象數量很高。,單個GC需要花更多的時間來完成,從而導致更高的暫停時間。而考慮到低暫停時間,最好頻繁運行GC以便更快速完成,反過來有導致吞吐量下降。
- 通過參數 -XX:GCTimeRadio 設置垃圾回收時間占總 CPU 時間的百分比。
- 通道參數 -XX:MaxGCPauseMills設置垃圾處理過程最久停頓時間。
- 通過命令 -XX:+UseAdaptiveSizePolicy 開啟自適應策略。我們只要設置好堆的大小和MaxGCPauseMillis或GCTimeRadio,收集器會自動調整新生代的大小、Eden和Survivor的比例、對象進入老年代和年齡,以最大程度上接近我們設置的MaxGCPauseMills或GCTimeRadio。
老年代垃圾收集器
Serial Old 垃圾收集器(單線程)
Serial Old 收集器是 Serial 的老年代版本,都是單線程收集器,只啟用一條 GC 線程,都適合客戶端應用。它們唯一的區別就是:Serial Old 工作在老年代,使用“標記-整理”算法;Serial 工作在新生代,使用“復制”算法。
Parallel Old 垃圾收集器(多線程)
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
CMS垃圾收集器
CMS(Concurrent Mark Sweep,并發標記清除)收集器是以獲取最短回收停頓時間為目標的收集器(追求低停頓),它在垃圾收集時使得用戶線程和GC線程并發執行,因此在垃圾收集過程中用戶也不會感到明顯的卡頓。
- 初始標記:Stop The World,僅使用一條初始標記線程對所有與GC Roots直接關聯的對象進行標記。
- 并發標記:使用多條標記線程,與用戶線程并發執行。此過程進行可達性分析,標記出所有廢棄對象,速度很慢。
- 重新標記:Stop The World,使用多條標記線程并發執行,將剛才并發標記過程中新出現的廢棄對象標記出來。
- 并發清除:只使用一條GC線程,與用戶線程并發執行,清除剛才標記的對象。這個過程非常耗時。
并發標記與并發清除過程耗時最長,且可以與用戶一起工作,因此,總體來說,CMC收集器的內存回收過程是與用戶線程一起并發執行的。
CMS的缺點:
- 吞吐量低
- 無法處理浮動垃圾
- 使用“標記-清除”算法產生碎片空間,導致頻繁Full GC
對于產生碎片空間問題,可以通過開啟 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都會進行一次內存壓縮整理,將零散在各處的對象整理到一塊。
設置參數-XX:CMSFullGCsBeforeCompaction 告訴 CMS,經過了 N 次 Full GC 之后再進行一次內存整理。
G1通用垃圾收集器
G1是一款面向服務端應用的垃圾收集器,它沒有新生代和老年代的概念,而是將堆劃分為一塊塊獨立的Region。當要進行垃圾收集時,首先估計每個Region中垃圾的數量,每次都從垃圾回收價值最大的Region開始回收,因此可以獲得更大的回收效率。
從整體上看,G1 是基于“標記-整理”算法實現的收集器,從局部(兩個 Region 之間)上看是基于“復制”算法實現的,這意味著運行期間不會產生內存空間碎片。
每個 Region 都有一個 Remembered Set,用于記錄本區域中所有對象引用的對象所在的區域,進行可達性分析時,只要在 GC Roots 中再加上 Remembered Set 即可防止對整個堆內存進行遍歷。
如果不計算維護Remembered Set的操作,G1收集器的工作過程分為以下幾個步驟:
- 初始標記:Stop The World,僅使用一條初始標記線程對所以后與GC Roots直接關聯的對象進行標記。
- 并發標記:使用一條標記線程與用戶線程并發執行。此過程進行可達性分析,速度很慢。
- 最終標記:Stop The World,使用多條標記線程并發執行。
- 篩選回收:回收廢棄對象,此時也要Stop The World,并使用多條篩選回收線程并發執行。
五、內存分配與回收策略
對象的內存分配,就是堆上分配(也可能經過JIT編譯后備拆散為標量類型并間接在棧上分配),對象主要分配在新生代的Eden區上,少數情況下可能直接分配在老年代,分配規則不固定,取決于當前使用的垃圾收集器組合以及相關的參數配置。
以下為普遍的內存分配規則:
- 對象優先在Eden分配
- 大對象直接進入老年代:-XX:PretenureSizeThreshold 參數,令大于這個設置值的對象直接在老年代分配
- 長期存活的對象將進入老年代: -XXMaxTenuringThreshold 設置新生代的最大年齡
- 動態對象年齡判定
- 空間分配擔保
可能會觸發JVM進行Full GC的情況
- System.gc()方法的調用:此方法的調用時建議JVM進行Full GC,注意這只是建議而非一定,但在很多情況下它會觸發Full GC,從而增加Full GC的頻率。通常情況下我們只需要讓虛擬機自己去管理內存即可,我們可以通過 -XX:+ DisableExplicitGC 來禁止調用System.gc()。
- 老年代控件不足:老年代控件不足會觸發Full GC操作,若進行該操作后空間依然不足,則會拋出如下處理java.lang.OutOfMemoryError: Java heap space
- 永久代空間不足:JVM規范中運行時數據區域中的方法區,在HotSpot虛擬機中已也稱為永久代,永久代可能會被占滿,會觸發 Full GC。如果經過 Full GC 仍然回收不了,那么 JVM 會拋出如下錯誤信息:java.lang.OutOfMemoryError: PermGen space
- CMS GC時出現promotion failed 和 concurrent mode failure promotion failed,就是上文所說的擔保失敗,而 concurrent mode failure 是在執行 CMS GC 的過程中同時有對象要放入老年代,而此時老年代空間不足造成的。
- 統計得到的Minor GC晉升到舊生代的平均大小大于老年代的剩余空間。
六、JVM性能調優(后期還得看)
在高性能硬件上部署程序,目前主要有兩種方式:
- 通過64位JDK來使用大內存;
- 使用若干個32為虛擬機建立邏輯集群來利用硬件資源。
使用 64 位 JDK 管理大內存
++++
使用 32 位 JVM 建立邏輯集群
++++
調優案例分析與實戰
++++
七、類文件結構
JVM的“無關性”
談論JVM的無關性,主要有以下兩個:
- 平臺無關性:任何操作系統都能運行Java代碼
- 語言無關性:JVM能運行除Java以外的其他代碼
Class文件結構
Class文件是二進制文件,它的內容具有嚴格的規范,文件中沒有任何空格,全都是連續的0/1。
Class文件中的所有內容被分為兩種類型:無符號數、表。
- 無符號數:無符號數表示Class文件中的值,這些值沒有任何類型,但有不同的長度。u1、u2、u4、u8分別代表1/2/4/8字節的無符號數。
- 表由多個符號數或者其他表作為數據項構成的符合數據類型。
魔數
Class 文件的頭 4 個字節稱為魔數,用來表示這個 Class 文件的類型。Class 文件的魔數是用 16 進制表示的“CAFE BABE”。
版本信息
緊接著魔數的 4 個字節是版本信息,5-6 字節表示次版本號,7-8 字節表示主版本號,它們表示當前 Class 文件中使用的是哪個版本的 JDK。
高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能運行以后版本的 Class 文件,即使文件格式并未發生任何變化,虛擬機也必須拒絕執行超過其版本號的 Class 文件。
常量池
版本信息之后就是常量池,常量池中存放兩種類型的常量:
- 字面值常量:定義的字符串、被final修飾的值
- 符號引用:類和接口的全限定名、字段的名字和描述符、方法的名字和描述符
常量池的特點
- 常量池中常量數量不固定,因此常量池開頭放置一個u2類型的無符號數,用于存儲當前常量池的容量。
- 常量池的每一項常量都是一個表,表開頭的第一位是一個u1類型的標志位(tag),代表當前這個常量屬于哪種常量類型。
訪問標志
在常量池結束之后,緊接著的兩個字節代表訪問標志, 這個標志用于識別一些類或者接口層次的訪問信息,包括:這個Class是類還是接口;是否定義為public類型;是否被abstract/final修飾。
類索引、父類索引、接口索引集合
類索引和父類索引都是一個 u2 類型的數據,而接口索引集合是一組 u2 類型的數據的集合,Class 文件中由這三項數據來確定類的繼承關系。類索引用于確定這個類的全限定名,父類索引用于確定這個類的父類的全限定名。
字段表集合
字段表集合存儲本類涉及到的成員變量,包括實例變量和類變量,但不包括方法中的局部變量。
方法表集合
方法表結構與屬性表類似。
volatile關鍵字和transient關鍵字不能修飾方法,所以方法表的訪問標志中沒有ACC_VOLATILE和ACC_TRANSIENT標志。
方法表的屬性表集合中有一張Code屬性表,用于存儲當前方法經編譯器編譯后的字節碼指令。
屬性表集合
每個屬性對應一張屬性表,屬性表的結構如下:
類型 | 名稱 | 數量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
八、類加載的時機
類的生命周期
類從被加載到虛擬機內存開始,到卸載出內存位置,它的整個生命周期包括以下7個階段:
- 加載
- 驗證
- 準備
- 解析
- 初始化
- 使用
- 卸載
驗證、準備、解析3個階段統稱為連接。
加載、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始),而解析階段則不一定:它在某些情況下可以在初始化后再開始,這是為了支持Java語言的運行時綁定。
類加載過程中“初始化”開始的時機
Java虛擬機規范沒有強制約束類加載過程的第一階段(即:加載)什么時候開始,但對于“初始化”階段,有著嚴格的規定。有且僅有5種情況必須立即對類進行“初始化”:
- 在遇到new、putstaitc、getstatic、invokestatic字節碼指令時,如果類尚未初始化,則需要先觸發其初始化。
- 對類進行反射調用時,如果類還沒有初始化,則需要先觸發其初始化。
- 初始化一個類時,如果其父類還沒有初始化,則需要先初始化父類。
- 虛擬機啟動時,用于需要指定一個包含main()方法的主類,虛擬機會先初始化這個主類。
- 當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類還沒初始化,則需要先觸發其初始化。
這5種場景中的行為稱為對一個類的進行主動引用,除此以外,其它所有引用類的方式都不會觸發初始化,稱為被動引用。
接口的加載過程
接口加載過程與類加載過程稍有不同。
當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,并不要求其父接口全部都完成了初始化,當真正用到父接口的時候才會初始化。
九、類加載的過程
類加載過程包括5個階段:加載、驗證、準備、解析和初始化。
加載
在加載階段,虛擬機需要完成3件事:
- 通過類的全限定名獲取該類的二進制字符流。
- 將二進制字節流所代表的靜態結構轉換為方法區的運行時數據結構。
- 在內存中創建一個代表該類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
獲取二進制字符流
對于Class文件,虛擬機沒有指明要從哪里獲取、怎樣獲取,除了直接從編譯的.class文件中讀取,還有以下幾種方式:
- 從 zip 包中讀取,如 jar、war 等;
- 從網絡中獲取,如 Applet;
- 通過動態代理技術生成代理類的二進制字符流;
- 由JSP文件生成對應的Class類;
- 從數據庫中讀取,如有些中間件服務器可以選擇把程序安裝到數據庫中來完成程序代碼在集群間的分發。
"非數組類"與"數組類"加載比較
- 非數組類加載階段可以使用系統提供的引導類加載器,也可以由用戶自定義的類加載器完成,開發人員可以通過定義自己的類加載器控制字節流的獲取方式(如重寫一個類加載器的loadClass()方法)。
- 數組類本身不通過類加載器創建,它是有Java虛擬機直接創建的,再由類加載器創建數組中的元素類。
注意事項
- 虛擬機規范未規定 Class 對象的存儲位置,對于 HotSpot 虛擬機而言,Class 對象比較特殊,它雖然是對象,但存放在方法區中。
- 加載階段與連接階段的部分內容交叉進行,加載階段尚未完成,連接階段可能已經開始了。但這兩個階段的開始時間仍然保持著固定的先后順序。
驗證
驗證階段確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。
準備
準備階段是正式為類變量(或稱“靜態成員變量”)分配內存并設置初始值的階段。這些變量(不包括實例變量)所使用的內存都在方法區中進行分配。
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。
初始化
類初始化階段是類加載過程的最后一步,是執行類構造器<clinit>()方法的過程。
<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static {} 塊)中的語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。
靜態語句塊中只能訪問定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊中可以賦值,但不能訪問。
<clinit>()方法不需要顯式調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。
由于父類的<clinit>()方法先執行,意味著父類中定義的靜態語句塊要優先與子類的變量賦值操作。
<clinit>() 方法不是必需的,如果一個類沒有靜態語句塊,也沒有對類變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。
接口中不能使用靜態代碼塊,但接口也需要通過<clinit>()方法為接口中定義的靜態成員變量顯式初始化。但接口與類不同,接口的<clinit>()方法不需要先執行父類的<clinit>()方法,只有當父接口中定義的變量使用的,父接口才會初始化。
虛擬機會保證一個類的<clinit>()方法在多線程環境中被正常加鎖、同步。如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法。
十、類加載器
類與類加載器
判斷類是否"相等"
任意一個類,都由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,每一個類加載器,都有一個獨立的類名稱空間。
因此,比較兩個類是否“相等”,只有在這個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class文件,被同一個虛擬機加載,只有加載它們的類加載器不同,那么這兩個類就必定不相等。
這里的“相等”,包括代表類的 Class 對象的 equals() 方法、isInstance() 方法的返回結果,也包括使用 instanceof 關鍵字做對象所屬關系判定等情況。
加載器種類
- 啟動類加載器(Bootstrap ClassLoader):負責將存放在<JAVA_HOME>\lib目錄中的,并且能被虛擬機識別的(僅按照文件名識別,如rt.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機內存中。
- 擴展類加載器(Extension ClassLoader):負責加載<JAVA_HOME>\lib\ext目錄中的所有類庫,開發者可以直接使用擴展類加載器。
- 應用程序類加載器(Application ClassLoader):由于這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱它為“系統類加載器”。它負責加載用戶類路徑(classpath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
當然,如果有必要,還可以加入自己定義的類加載器。
雙親委派模型
什么是雙親委派模型
雙親委派模型是描述類加載器之間的層次關系。它要求除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。(父子關系一般不會以繼承的關系實現,而是以組合關系來復用父加載器的代碼)
工作過程
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給輔流加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(找不到所需的類)時,子加載器才會嘗試自己去加載。
在java.lang.ClassLoader中的loadClass方法中實現該過程。
為什么使用雙親委派模型
像java.lang.Object這些存放在rt.jar中的類,無論使用哪個類加載器加載,最終都會委派給最頂端的啟動類加載器加載,從而給使得不同加載器加載的Object類都是同一個。
相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個成為java.lang.Object的類,并放在classpath下,那么系統將會出現多個不同的Object類,Java類型體系中最基礎的行為也就無法保證。
參考
- JVM相關
- JVM 底層原理最全知識總結
- ?JVM相關知識體系詳解?