1. 程式人生 > >java虛擬機詳解

java虛擬機詳解

image 種類 棧的操作 cms gc float align pool 不定 記錄

註:

此篇文章可以算是讀《深入理解Java虛擬機:JVM高級特性與最佳實踐》一書後的筆記總結加上我個人的心得看法。

整體總結順序沿用了書中順序,但多處章節用自己的話或直白或擴展的進行了重新的理解總結,而非單純摘錄。

Java內存區域簡介

運行時數據區域

程序計數器

又稱“PC”。是一塊很小的內存空間。

jvm最終會將java文件編譯成字節碼指令,通過字節碼指令來執行程序。

而程序計數器的作用就是指明“當前線程需要執行的字節碼指令”。

程序開始執行前,程序計數器的值,對應的就是“第一條字節碼指令”,

當第一條字節碼指令執行完畢後,“字節碼解釋器”會改變程序計數器的值,使其對應下一條要執行的字節碼指令。

處理器就是根據“程序計數器的值”來決定當前執行那一條“字節碼指令”(即:程序計數器決定了字節碼指令的執行順序)。

在多線程的環境下,為了線程切換後也能恢復到每條線程的字節碼的正常執行位置,

所以每條線程都有一個獨立的程序計數器(線程私有的內存空間)。

此內存區域是jvm規範中唯一沒有規定任何OutOfMemoryError情況的區域。

虛擬機棧

線程私有,生命周期與線程相同。

棧是描述“java方法執行”的內存模型。

棧中存儲的單位是“棧幀”,一個方法對應一個棧幀,也可以說是以幀為單位保存當前線程的運行狀態。

棧頂的棧幀被稱為“當前棧幀”,當一個線程執行一個方法時,jvm就會往該線程對應的棧中壓入一個棧幀,這個棧頂棧幀自然就是當前棧幀。

棧幀大致由三部分構成:局部變量區、操作數棧

局部變量表

是一組變量值的存儲空間,裏面存放了棧幀對應的方法的參數和內部定義的局部變量、還有“對象的引用”。

其中long和double會占用2個局部變量空間,其余的數據類型只占一個。

局部變量所需的內存空間在編譯時就會分配完成。

操作數棧

可以將操作數棧看成“臨時存儲區域”。

操作數棧中存儲的數據和“局部變量表”中存儲的數據相同。

操作數棧的操作方式顧名思義,是通過將數據壓棧和將數據彈棧來操作的。

之所以將其稱為“臨時存儲區域”是因為:

虛擬機的解釋執行引擎會將操作數棧作為它的“工作區”,如下圖:

技術分享

解釋執行引擎的前兩條字節碼指令先依次將需要操作的數據壓入“操作數棧”,

之後再由指令將其依次彈出並相加,再將結果再壓入棧中,

最後再通過指令,將“操作數棧”中的結果彈出並裝入“局部變量表”。

由此可見,操作數棧是作為一個“臨時的存儲區域”而存在的,也是一個臨時的工作區。

本地方法棧

與虛擬機棧的作用類似,它們之間的區別是虛擬機棧為虛擬機執行字節碼服務,而本地方法棧為虛擬機使用“系統方法”的服務。

java堆

java堆是所有線程共享的區域。

此區域的唯一目的就是存放對象實例以及數組。但對象並不一定都得存在堆上。

java堆是垃圾收集器管理的主要區域,java堆可分為:新生代和老年代。

雖然是線程共有的,但是可以將堆劃分成多個線程私有的分配緩存區。

不需要連續的內存空間。

方法區

方法區是所有線程共享的區域。

方法區用來存儲已被虛擬機加載的:類信息、常量、靜態變量、即時編譯器編譯後的代碼。

方法區主要存儲的都是一些長期存在,不易回收的數據。

不需要連續的內存空間。

方法區可以理解成class文件在內存中的存放位置。

而class文件除了各種描述信息以外,還有一項信息就是:常量池。

常量池

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

常量池分為兩種形態:靜態常量池和運行時常量池。

靜態常量池:

即每一個.class文件的常量池,靜態常量池中包含了:字符串的字面量,類、方法的信息,

占用了class文件的絕大部分空間。

運行時常量池:

運行時常量池存在於內存中,

是“靜態常量池加載到內存之後的版本”。

其存儲的常量並不一定是在編譯時產生的,在程序運行期間產生的常量也存入運行常量池。(如String類的intern()方法)

class文件中有一部分信息,如字符串的字面量等各種字面量、符號引用,

對象

對象的創建簡單流程

當虛擬機在解析時遇到一個new指令,

1、找到類

首先在當前class文件的常量池中查找對應類的符號引用。

再檢查該符號引用是否已被加載、解析、初始化。如果沒有,就必須進行相應的類加載過程。

2、分配內存

接下來虛擬機需為新生對象分配內存(所需內存大小在類加載時就已確定)。

3、為內存初始化“零”值

內存分配完後,jvm會將剛剛分配的內存空間中的數據類型賦“零”值。

(如定義了一個屬性:String s,並沒有賦值,為了保證該變量能正常使用,在該處會為其賦一個null)

每種數據類型都有自己的“零”值,具體數據類型對應的“零”值如下:

數據類型

“零”值

int

0

long

0L

short

(short)0

char

‘\u0000‘

byte

(byte)0

boolean

false

float

0.0f

double

0.0d

reference

null

4、設置對象頭

對象頭中存儲了對象的“自身運行時數據(hash碼、GC年齡等)”和“指向“所屬類”的指針”。

5、執行對象的構造方法。

將對象裝入棧,然後執行構造方法的字節碼指令。

(關於這個“這個執行構造方法”,

之前賦“零”值的時候,對象中的每個數據類型都是其特有“零”值,

現在構造方法會先對其賦“正在定義的值”(如對象中有int i = 666,之前內存初始化時,i賦值為0,此時會把i賦值為666),

然後再執行具體的構造方法。)

對象所需內存空間的分配方式

分配方式有兩種:

指針碰撞

如果內存為規整的,

已用的內存在一邊,空閑的內存再另一邊,他們之間放著一個分界點指示器。

那麽分配內存就僅僅只是把指示器向空閑區域移動一段與對象大小相等的距離距離即可。

空閑列表

如果內存不規整,

那麽虛擬機需要維護一張“記錄表”,上面記錄了那一塊內存區域是可用的。

在分配內存時,只需要按照表上的地址找到內存區域後,再更新表即可。

內存是否規整與垃圾收集齊的GC規則來決定。

為了保證線程安全(即防止:在給a對象分配內存,指示器還沒來得及修改,就又需要給b對象分配內存的情況)

目前有兩種方案解決:

1、JVM采用CAS配上“失敗重試”的方法。

2、把內存提前劃分成多個小塊給線程,每一小塊都相當於是線程的私有內存。稱之為“本地線程分配緩沖”。

對象在內存中的布局

每個對象的布局可分為以下三個區域

對象頭

包含兩部分:

1、對象hash碼、對象分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程id、偏向時間戳等。

這部分屬於對象運行時會產生的數據。

2、類型指針,指向對象的類元數據(該指針並不是必須存在的,即查找對象的類元數據並不一定要經過對象本身)。

3、(只有對象為數組時才有此項)對象頭中還有一塊用於記錄數組長度的數據。

實例數據

程序代碼中定義的各種類型的字段的內容,無論是父類中繼承的,還是子類定義的。

存儲順序由虛擬機的分配策略決定(HotSpot默認策略為:long/doubles、inits、shorts/chars、bytes/booleans、oops)

對齊填充

此部分不是必然存在,僅僅起到占位符的作用,占到8字節的整數倍。

對象與對應類元數據的訪問定位

我們通過棧上的“對象的引用(reference)”找到堆中的對象,然後操作它。

目前通過reference定位對象的方式有兩種

句柄訪問

java堆中會劃分出一塊內存作為“句柄池”,每一個reference對應一個句柄,而句柄中包含了“到對象實例的指針”和“到方法區中類元數據的指針”。

reference通過找到對應句柄可以直接找到對象或類。

這種訪問方式的好處是“穩定”,對象移動時,只需要改變句柄即可,不需要改變reference

直接指針

reference存儲的直接就是對象地址,而對象的類元數據地址指針存儲在對象頭中。

好處是速度快(因為通過reference可以直接找到堆中的對象)。

垃圾收集器

判斷對象是否存活算法

引用計數算法

當對象每一次被引用計數器就+1,

當引用時效(如將引用變量賦值成null,或“含有該引用變量的棧幀出棧”)

當引用計數器為0時,就將其回收。

最主要的缺點就是不能解決對象之間的循環引用問題。

可達性分析算法

JVM從幾塊區域的“根引用”(GC Roots)處開始分析。

即:從一個引用處,查找它所引用的對象,再看該對象有沒有引用其他對象·····

如果整個程序中所有的“根引用”(GC Roots)都順著找完了,發現有些對象不在這些“引用鏈”上,

就回收這些對象。

可以作為“根引用”(GC Roots)的引用包括:

1、JVM棧的本地變量表中的引用。

2、方法區中類的靜態屬性引用。

3、方法區中的常量引用。

4、本地方法棧中的JNI(Native方法)引用。

如果對象在進行可達性分析後發現沒有與GCRoots鏈相連,

那它會被“第一次標記”並進行一次篩選,篩選條件是此對象是否有必要執行finalize()方法(對象沒有覆蓋finalize()方法或該對象的finalize()方法已經被執行過一次了(一個對象的finalize()方法只會被系統調用一次),都表示沒必要執行finalize()方法)。

如果被判定有必要執行finalize()方法,則不會立即回收該對象,而是將其放置在一個叫“F-Queue”的隊列中,

然後jvm會用一條自建線程去啟動隊列中的finalize()方法,

最後jvm會對隊列裏的對象進行“第二次標記”並進行篩選。

jvm用一條自建的低優先級的線程去觸發該finalize()方法時,如果此finalize()中將該對象重新與引用鏈關聯,則後面進行“第二次標記時”將其移除“即將回收的集合”(不被回收),

否則第二次篩選標記時會被回收。

“F隊列”中對象的finalize()方法不保證一定會被“執行完畢”(害怕finalize執行緩慢,導致f隊列中的其他對象一直等待)。

finalize()運行代價高昂,不確定性大,不建議使用。

引用的強度分類

強引用(Strong Reference)

new出來的對象都是強引用,只要對象存在強引用,就不會被回收。

寫法:Object obj = new Object();

軟引用(Soft Reference)

通常描述一些“有用,但非必須的對象”,

當內存不足時,會將這些對象列入回收範圍,進行二次回收。如果這次回收還是沒有足夠的內存,才會拋內存溢出異常。

寫法:SoftReference<T> sRefer = new SoftReference (t);

通過get方法獲得強引用:T t= sRefer.get();

弱引用(Weak Reference)

也是用來描述非必須對象,但強度比軟引用更弱。

弱引用引用的對象只能活到下一次GC發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉弱引用引用的對象。

寫法:WeakReference<T>weakReference=new WeakReference<T>(t);

虛引用(Phantom Reference)

這是最弱的一種引用關系。

虛引用無法影響到對象的生存時間(即:就算加上虛引用,jvm也不會將其視為對象的引用),也無法通過虛引用獲取到一個對象實例。

虛引用唯一的目的就是:當被虛引用引用的對象被回收時,可以收到一個系統通知,表示該對象被回收了。

回收方法區

之前說過了,方法區中存儲的都是常量或類的元數據,所以方法區的回收也是針對這兩種數據的回收。

廢棄常量:假如沒有任何對象引用該常量,就回收它。

無用的類:回收類需要類滿足以下三個條件;

1、該類的所有實例都已被回收(即不存在)

2、加載該類的classloader已經被回收

3、該類的java.lang.Class對象沒有被引用(即任何地方都沒有通過反射來獲取該類)

垃圾收集算法

標記-清除算法

如同名字,顯示標記出需要回收的對象,然後統一回收。

這是最基本的GC算法,

主要有兩個不足:

1、效率不高

2、會產生大量不連續的內存碎片。

復制算法

為解決上面提到的“效率問題”,出現了“復制算法”。

目前商業虛擬機采用的方式為:

將內存分為三塊:一塊較大的Eden空間(占80%)和兩塊較小的Survivor空間(各占10%),

由於新生代中的對象98%都是很快就消亡的,所以每一次回收都能回收掉幾乎98%的空間。

由此,我們每一次使用Eden空間和一塊Survivor空間,然後回收時,將其中存活的對象復制到“未被使用”的Survivor空間中,再清除剛剛的Eden空間和剛剛用的Survivor空間。

(相當於:由於每次回收時只有少量對象能存活,所以每次使用內存的90%空間來存對象,然後需要回收時,把裏面存活的少量對象復制到沒被用的10%的空間中,然後整體清空那90%空間)

當然,並不一定保證存活的對象所需空間小於survivor那10%的空間。

當預留的10%空間不夠時,需要依賴老年代進行“分配擔保”。

所謂“分配擔保”就是將survivor區無法容納的對象“直接晉升為老年代”。

這樣的話就需要老年代中有足夠的空間可以容納這些多出來的存活對象。

老年代需要做以下處理,計算“當前老年代中有沒有足夠的空間能容納這些對象”:

如果之前也發生這種“晉升”情況,那麽此時老年代會把之前每次晉升時需要的空間算一個“平均值”,與此時老年代擁有的空閑空間做一個比較,來決定是否需要讓老年代“騰出更多的空間來裝對象”。

標記-整理算法

剛剛提到的“復制算法”明顯不適用於“老年代”,因為老年代中的對象都是長時間存在的,一次GC會活下來大部分,使用“復制算法”得不償失。

所以就有了“標記-整理算法”。

此算法一開始也是標記出所有需要回收的對象。

之後讓所有存活的對象都向一邊移動,最後清理掉端邊界以外的內存。

優點是不會產生內存碎片。

缺點就是存活對象的移動會降低GC效率。

GC算法實現

枚舉根節點

之前說過了,如果要回收某一個對象,就得判斷該對象是否被引用,而判斷的方式一般又選擇“可達性分析算法”。

這裏就牽扯到了兩個問題:

1、通過之前我們對可達性算法的分析可知,在算法進行時(從GCRoots們開始遍歷查找時),對象之間的引用關系是不能改變的。

所以在GC時必須停頓所有線程。

2、每一次GC都要從所有根引用(GC Roots)開始遍歷太慢了,所以有一個類似於Map的數據結構“OopMap”存儲了:“棧或方法區上哪些地方對對象進行了引用”,jvm可根據這個map表直接發現 那個對象在哪裏被引用了,從而回收掉剩余的對象。

安全點

但接下來就有一個問題:“幾乎每時每刻對象之間的引用關系隨著程序的進行都在發生變化”。也就是說OopMap的內容每時每刻都在變化,如果隨時都維護著一張OopMap代價太大了。

所以只在某些特定的地方記錄當前程序的OopMap。我們稱這種地方為“安全點”,當程序運行到安全點時,虛擬機再進行可達性分析然後GC。

安全點主要在:

1、循環的末尾。

2、方法return之前、調用方法之後。

3、可能拋出異常的位置。

假如是多線程的環境,那麽其中一條線程到達安全點後,必須使每條線程都到達它所在的安全點後,整個系統才能開始GC。

(因為一條線程如果在可達性分析時,其他線程依舊有可能改變該線程的引用關系,所以GC必須所有線程都停)

目前有兩種方式使得所有線程都跑到安全點:

1、搶先式中斷:

當某一條線程到安全點後,就表示需要GC了。此時直接中斷剩余的所有線程。

如果發現有線程不在安全點上,就恢復該線程,讓它跑到安全點上。保證線程都到安全點後就開始GC。

現在幾乎沒有虛擬機采用這種模式了。

2、主動式中斷:

每一個線程在執行時都時刻檢查當前位置是不是安全點。如果是安全點的話,就先中斷該線程,然後掛起,

等到所有線程都跑到安全點後,就開始GC。

安全區域

上面的主動式中斷其實有個bug,

這個漏洞就是“必須所有線程都走到安全點上才能GC”,可假如有某條線程暫時不執行呢?(如sleep)那麽該線程永遠無法到安全點,其他線程也都沒法GC。

對於這種情況就需要“安全區域”來解決。

安全區域是指“在一段代碼片段中,引用關系不會發生變化”,這個區域的任何地方GC都是安全的。

所以如果某條線程處於sleep狀態,他也就處於了安全區域,隨時都能GC。

垃圾收集器

1、Serial收集器

新生代使用,采用復制算法。

單線程,在GC時必須暫停所有工作線程,知道收集結束。

但在客戶端的java程序上很有效(因為客戶端產品多線程需求不大)

2、ParNew收集器

新生代使用,采用復制算法。

多線程,默認開啟的線程收集數與cpu數量相同。

除了Serial外,只有該收集器可以和老年代的CMS收集器合作。所以為java服務端的首選新生代收集器。

3、Parallel Scavenge收集器

新生代使用,采用復制算法。

多線程。

該收集器的特點是:“可以控制吞吐量”

即吞吐量 = 運行用戶代碼的時間/(運行用戶代碼的時間+垃圾收集的時間)

也就是:吞吐量 = 程序運行時間/總時間

該收集器適合運行“在後臺運算而不需要太多交互的任務”

4、Serial Old收集器

老年代使用,采用標記整理算法。

單線程。

該收集器的目的也是給客戶端的java程序使用。

5、Parallel Old收集器

老年代使用,采用標記整理算法。

多線程。

可以與Parallel Scavenge收集器相組合。

6、CMS收集器

老年代使用,采用標記清除算法。

多線程。

GC回收可與用戶線程一起進行。

但也有缺點:

1、雖然可與用戶線程一起執行,但GC時會占用cpu,導致程序變慢。

2、在CMS GC時也會產生垃圾,這些垃圾沒法被回收,只能留到下一次回收。

3、由於是基於標記清除算法,必然會造成內存碎片。碎片太多,大對象分配內存會空間不夠,導致提前GC(為了給大對象騰位置)。

7、G1收集器

最先進的收集器。

新生代和老年代都能使用。

GC時可與程序並發運行。

會空間整理,不會產生碎片。

可以預測停頓。

類文件結構

class文件沒有任何分隔符,所以所有數據項的大小與順序都被嚴格規定了。

其具體的數據項順序如下:

魔數(magic)

數量:1

大小:4字節

解析:確定這個文件是否能被jvm接收。

由於文件的擴展名可以隨意改動,出於安全考慮,通過魔數來決定改文件是否能被jvm接收,而不是擴展名。

class文件次版本號(minor_version)

數量:1

大小:2字節

class文件主版本號(major_version)

數量:1

大小:2字節

解析:java版本號其實是用45開始的,每次主版本更新都加一,如1.7對應的就是51

常量池計數值(constant_pool_count)

數量:1

大小:2字節

解析:指明常量池中“常量的個數”。(即,常量表的個數)

註:是從1開始的而不是從0開始。所以假設值為6,則意味著常量池中有5個常量。

常量(池)(constant_pool)

數量:constant_pool_count-1

大小:不定,

解析:

整個常量池相當於這個類的“資源倉庫”。之後的各項目會經常用到常量池中的常量。

這部分由多個常量“表”構成,“每一個常量都是一張表”(即該class中有多少常量,在此處就有多少張常量表。一個常量表只代表一個常量),每種表都有自己的結構。

各個常量表再常量池中的出現順序沒有硬性的要求。

常量池中主要存放了兩大類常量

字面量:如字符串、聲明為final的常量值等。

符號引用:類或接口的名稱(是全限定名稱,包括類之前的包名等)。屬性的名稱和描述符。方法的名稱和描述符。

註意:符號引用最後基本上都是指向某個“字面量”!

字面量常量表:CONSTANT_Utf8_info(代表一個utf8編碼的字符串),偏移地址為0x0002,值為"fun1"。

符號引用常量表:CONSTANT_NameAndType_info(方法或屬性的名稱和描述符的符號引用),該表中的“名稱index”項的值就是“0x0002”,表示指向上面那個字面量常量表。

(這裏多說一下這個符號引用,

jvm在將java代碼編譯成class文件時,class文件中不會保存各個方法和屬性的“真正內存布局”,連指向這些方法的“真正存在地址”的直接引用都不會存在。

只會有一個符號引用(可以理解為就是一個占位符),等到“加載類的時候”,才會把這個“符號引用”變成“直接引用”,然後可以通過這個直接引用來找到指定的方法或屬性)。

常量池中可以存放的常量種類一共有14種。

就像之前說的,每一種常量都是一張“有著獨特結構的表”。

不過每一張表的“第一個字節”都是一個“索引值”(14種表就有14種索引值),表示告訴jvm:“現在開始的是哪一種表”。

然後索引值後面才是該表的具體內容。

我們舉上14種常量的三種的表結構來看一下:(再說明一下,各 個常量表在class文件中的出現順序與索引值的順序)

CONSTANT_Integer_info

tag(數量:1):索引值,為3

bytes(數量:1):存儲int的值,該項占4個字節

CONSTANT_Class_info

tag(數量:1):索引值,為7

index(數量:1):指向“全限定名”常量索引,即指向的是”該常量池中的某一個CONSTANT_Utf8_info“

這就是一個符號引用。

類似的還有:CONSTANT_Methodref_info(方法的符號引用)等各種符號引用。

CONSTANT_Utf8_info

tag(數量:1):索引值,為1

length(數量:1):該utf8編碼的字符串長度(單位是字節),“也用來定義接下來byte的數量”

bytes(數量:length):字節長度有多少,就有多少個該項,該項就是該字符串的具體字節。

“在常量池中所有的字符串都是這麽存儲的”

(如:

假設某個類的全限定名為:org/fenixsoft/clazz/TestClass

那麽這個字符串“org/fenixsoft/clazz/TestClass”就是存儲在常量池中的某個CONSTANT_Utf8_info中。

而該類的索引CONSTANT_Class_info中的index,指向這個CONSTANT_Utf8_info)。

訪問標誌

常量池後面緊跟的兩個字節就是“訪問標誌”。

用來識別當前的類文件是類還是接口;是否是public;是否被申明成final等。

類索引

類索引用於確定當前類的“全限定名”。

索引的值是“常量池中對應的類常量項的偏移地址”

(如該類的常量項“在常量池中”的偏移地址為0x0001,

那麽此處的值也是0x0001)

通過類索引查找類的全限定名

(父類和接口的全限定名也是這麽找的。

根據偏移地址,

先找到該類在“常量池"中的常量項(CONSTANT_Class_info表),

再通過該常量項的index,找到對應的字符串常量項(CONSTANT_Utf8_info)的所在位置,

根據字符串常量項中的bytes,就能知道該類的全限定名字符串是什麽了。)

父類索引

用於確定當前類的“父類的全限定名”。

(由於java是單繼承,所以當前類的直接父類只會有一個)。

該索引的值是“常量池中對應的類常量項的偏移地址”,即CONSTANT_Class_info表。

接口計數器

由於一個類可以實現好多個接口,所以需要先用該值說明接口數量。

接口索引

接口索引的數量由上面的接口計數器決定。

每個接口索引的值是“常量池中對應的類常量項的偏移地址”,即CONSTANT_Class_info表。

類屬性數量

表示類中有多少屬性。

類屬性表集合

類中每一個屬性就是一張表,表的數量由之前的計數器來定。

註:此處的“屬性”指的不是我們常稱的屬性(即類中變量),而是類的“信息”。

這些信息包括:內部類的列表、方法的局部變量描述、源文件的名稱等信息。

每個屬性的名稱都需要從常量池中引用一個“CONSTANT_Utif8_info”類型的常量來表示。

Code屬性表

java程序的方法體代碼經過編譯後,最終變成“字節碼指令”,然後也存放到類的屬性表之一“Code表”中。

(接口中就沒有Code表,因為沒有方法體)

Code表中除了該類中方法體對應的字節碼外,還有一個“異常表”。

異常表中一共有四個字段:start_pc、end_pc、handler_pc、catch_type。

當start_pc行到end_pc行之間出現了catch_type或其子類的異常時,則跳轉到第handler_pc行繼續處理。

catch_type為0時,則任何異常都要跳轉到handler_pc行處理。

Exceptions屬性

列舉出類的所有方法中可能拋出的異常。

SourceFile屬性

用於記錄生成這個Class文件的源碼文件名稱。

ConstantVavlue屬性

該屬性作用是通知虛擬機“自動為靜態變量賦值”。

即,只有static類型的變量才有這個屬性。

之前提過,非static變量的初始化是在<init>方法中進行(也就是對象加載的最後一步,調用構造函數)

而static類型的變量的初始化有兩種方式:

1、在類加載時,由類構造器<clinit>初始化。

2、給該變量賦ConstantValue屬性的值。

InnerClasses屬性

如果一個類中定義了內部類,則編譯器會為該類生成InnerClasses屬性。

“類加載”機制

類“初始化”時機

類從加載到出內存,一共要經歷以下七個階段:

加載、驗證、準備、解析、初始化、使用、卸載。

(其中,驗證,準備,解析,這三個階段又統稱為“連接”)

類的加載、驗證、準備、解析要在“初始化之前完成”。

jvm沒有強制規定什麽時候開始加載。

但強制規定了類初始化的情況只有以下5種:

1、遇到相關字節碼指令時(如果該類沒初始化過,則初始化),即以下三種情況:

(1)、使用new關鍵字實例化對象時。

註:new一個數組時不會觸發初始化。

(2)、讀取一個類的靜態屬性字段時(被final修飾的靜態屬性除外,因為final常量在編譯時期已把數據放入常量池)。

註:只有“直接定義”該靜態屬性的類才會被初始化。如:B類中有一個靜態屬性b字段,A類繼承B類。調用A.b,只有B類會被初始化(因為,雖然是通過A類來調用的靜態字段,但B類才是“直接定義”該字段的類,所以只有B類會被初始化)。

1、調用一個類的靜態方法時。

2、使用java.lang.reflect包的方法對類進行反射調用時。(如果該類沒初始化過,則初始化)

3、初始化一個類時,如果其父類沒有初始化,則先初始化其父類。

4、jvm啟動時,用戶需要先指定一個主類(main方法那個類),jvm先初始化該類。

5、當時用java7的動態語言支持時。(如果該類沒初始化過,則初始化)

接口的加載與類的加載類似,區別只有一個在一個接口初始化時,並不需要全部初始化完所有父類接口。

“類加載”全過程

加載

主要就做了3件事:

1、通過一個類的全限定名來獲取此類的二進制流(關於獲取類的全限定名的方法可以看之前講解的常量池章)。

(此處並沒有指定二進制流必須從哪獲取,可以從class文件獲取,也可以運行時計算生成等)。

2、把該字節流轉化成方法區運行時的數據結構。

3、生成一個代表該類的Class類型對象,作為訪問該類在方法區中數據的入口。

(關於這個對象的存放地點有點特殊,雖然是對象,但不一定是存放在堆中,如HotSpot虛擬機就是把Class對象存放在方法區中)

數組類的加載過程有所不同。

數組類不是通過類加載器加載的,而是通過jvm直接創建的。

數組類的創建需要遵守以下規則:

1、如果數組裏面的組件是“引用類型(非基本數據類型,即類)”,則使用遞歸加載裏面的每一個類。

2、如果數組裏面的組件不是引用類型(如int[]),則直接將該數組與“Bootstrap加載器”關聯(關於bootstrap後面會詳講)。

3、數組類的可見性與其組件類的可見性一致。

註意:加載階段與連接階段(驗證+準備+解析)是“交叉進行”的,即加載可能還未完成,連接就已開始。

驗證(連接階段之一)

該階段的目的是:確保Class文件的字節流中包含的信息符合當前jvm的要求,並且確保字節流不會危害到jvm。

驗證大致分一下四個階段:

1、文件格式驗證

這一階段驗證的是字節流,主要驗證字節流是否符合Class文件的規範,並且能不能被當前版本的jvm處理。

(如:開頭魔數是否正確、主次版本號是否在當前虛擬機能處理的範圍之內等)

通過了這個驗證之後字節流就會存入內存中的方法區,“之後就不會再操作該字節流了”

2、元數據驗證

對該類的元數據進行驗證,如該類的繼承是否正確、該類是否實現了其接口中的方法等。

3、字節碼驗證

對該類中的各個“字節碼指令”進行驗證,如保證類型轉換的正確性、保證跳轉命令的正確性等。

4、符號引用驗證

該驗證發生在“解析階段”中,當jvm把符號引用轉化為直接引用時,

符號引用驗證的目的自然就是“保證轉化動作能正常進行”。

如,驗證能不能通過符號引用對應的全限定名找到對應的類、符號引用的類的訪問性能不能被當前類訪問到等。

準備(連接階段之一)

該階段作用是:“為該類中的常量、靜態變量在方法區中分配內存,並初始化”。

註意:

此時的初始化時為數據類型賦“零值”(與對象加載是變量賦“零值”一樣)。

如 public static in value = 123;

此處會將value初始化為0,而不是123.(把value賦值成123要等到“初始化階段”進行)

String也同理,會先初始化為null,而不是其具體值。

每種數據類型都有自己的“零”值,具體數據類型對應的“零”值如下:

數據類型

“零”值

int

0

long

0L

short

(short)0

char

‘\u0000‘

byte

(byte)0

boolean

false

float

0.0f

double

0.0d

reference

null

解析(連接階段之一)

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

符號引用在之前將常量池中已經說了,它們是以“常量表中的常量的形式出現”(如CONSTANT_Class_info表等)。

直接引用可以使指向目標的指針、相對偏移量、句柄等。

下面分別說一下7中符號引用(也就是常量池中的7種常量)的解析過程:

類或接口的解析

把全限定名傳遞給類加載器加載(類加載器的加載過程與現在正在進行的“類加載”流程一致)

加載完畢沒問題的話,就會正確的加載到方法區中。

於是就有了該類或接口的方法區的指針。

類中屬性的解析

通過常量池中“屬性常量表CONSTANT_Fieldref_info”可知,

每一個“屬性常量表”(CONSTANT_Fieldref_info)中都包含了兩個信息:

1、該屬性“所在類或接口”的符號引用(也就是CONSTANT_Class_info表)。

2、該屬性的名稱與修飾符的符號引用(也就是CONSTANT_NameAndType_info表)

可以看出解析類中屬性的流程基本就是:

先在方法區中找到該屬性所屬的類或接口(如果該符號引用還沒變成直接引用,就先對其進行解析,如上面一樣)。

再根據CONSTANT_NameAndType_info表中信息找到該類常量池中的“具體常量值”(具體找法可以參考之前的常量池介紹),

如果該類中沒找到,就遞歸搜索父類接口中的常量池,

如果父類接口的常量池中也沒有,就遞歸搜索父類中的常量池。

否則拋異常。

類中方法的解析

解析方法與“類中屬性”的解析方式一樣,

原因是:

常量池中,每一個“方法常量表”(CONSTANT_Methodref_info)中包含的還是上面那兩項,

即該方法所在類或接口的符號引用(CONSTANT_Class_info表)、和該方法的名稱與修飾符表的符號引用(CONSTANT_NameAndType_info表)。

所以加載方式還是上面那一套。

接口方法的解析

接口方法常量表為CONSTANT_InterfaceMethodref_info,

裏面還是上面那兩項,

所以加載步驟還是,先找所屬類,再找具體方法。

初始化

初始化階段就是執行“類構造器方法(<clinit>方法)”的過程。

“類構造器方法”與對象的“構造方法”是不一樣的。

之前將對象的創建流程時,提到,對象創建的最後一部也是初始化,即“運行構造方法”,構造方法會先“給類的“非靜態,非final”屬性賦具體的值”(之前是零值),再執行自定義的構造方法。

而類構造器方法也是這樣,目的是“給靜態變量,或靜態語句塊中的變量賦具體值”。(之前在“準備階段”時,這些靜態屬性都被賦了“零”值)。

類加載器

剛剛在將“類加載”的“加載”階段時說說了,jvm並沒有限定“如何通過“類的全限定名”來獲取此類的二進制字節流”,

以便讓程序可以自己決定“如何獲取類的二進制流”,而實現這個功能的代碼模塊稱為“類加載器”。

比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載時才有意義。否則這兩個類就是不相等的。

從jvm角度來講,只有兩種類加載器:

1、啟動類加載器(Bootstrap加載器),由c++實現,是jvm的一部分。

2、其他類的加載器,由java語言實現,在jvm外部,這些類都“繼承於ClassLoader”

類加載器的執行有先後順序,依次為:

啟動類加載器(Bootstrap ClassLoader)

該加載器負責加載jvm所需的系統級別類,如java.lang.String, java.lang.Object等等。

Bootstrap Classloader會讀取 {JRE_HOME}/lib 下的jar包和配置,然後將這些系統類加載到“方法區”內。

擴展類加載器(Extension ClassLoader)

由“sun.misc.Launcher.ExtClassLoader”類實現。

它負責加載JRE的擴展目錄(JAVA_HOME/jre/lib/ext或者由java.ext.dirs系統屬性指定的)中JAR的類包。

應用程序類加載器(Application ClassLoader)

由“sun.misc.Launcher.AppClassLoader”類實現。

作為程序中的默認“類加載器”。

我們自己創建的各種class,都是通過該加載器加載的。

自定義類加載器

用戶可以自定義類加載器,但要繼承java.lang.ClassLoader類。

然後可以指定使用。

雙親委派模型

所謂雙親委派模型就是指:除了Bootstrap加載器外,其余的類加載器都應當有自己的父類加載器(此處的父子關系一般指的不是繼承關系,而是通過組合關系“復用“父類”的代碼”)。

整個過程為:

當某個類加載器收到類加載請求時,它自己不會去嘗試加載該類,而是把請求交給“父類”加載器。

當“父類”加載器反饋說不能加載時,子類再加載。

好處是:

之前我們提到了,“任意的兩個類,只要不是同一個類加載器加載的,這兩個類就被判定為不相同”。

我們舉一個例子,假設Object類由Bootstrap加載器加載了,如果沒有雙親委派機制,我們又讓一個底層加載器再加載一個Object類,這樣就會出現兩個Object類,這個應用程序都有可能混亂。

而使用了雙親委派機制後,對於Object類來說,無論何時用哪一個類加載器來加載,最終都會送到最上層,讓Bootstrap加載器來加載,也就沒有上面那個問題了。

字節碼執行引擎

運行時棧幀結構

每一個方法從調用開始至執行完成的過程,都對應一個“棧幀”在虛擬機棧裏面從入棧到出棧的過程。

每一個棧幀都包含了以下各項,在編譯時期棧幀裏面的結構就已經確定了。

局部變量表

顧名思義,局部變量表是“變量值的存儲空間”,存放的是“方法參數”和“方法內定義的局部變量”。

局部變量表的容量的最小單位是“變量槽(Slot)”。

每個Slot占用32位長度內存空間。

所以一個Slot中可以存放boolean、byte、char、short、int、float、reference、returnAdress八種數據類型的數據。

而64位的數據只有long和double兩種。此處采用的存儲方法類似於“把一個long或double讀寫分割為兩次32位讀寫”(因為棧是線程私有,所以不需要考慮線程安全問題)。

為了節省棧幀空間,局部變量表中的slot是可以“重用”的。

即:

“某方法體的作用域中定義了一個變量,即使程序運行出了該變量的作用域,該變量依舊會存在(即不會被gc回收),而有新的變量需要存儲到slot時,才會更新覆蓋掉舊slot數據”。

還有一個細節就是:局部變量表中的變量沒有賦“零值”的操作。

之前的類初始化和對象初始化都會有數據賦“零”值的階段。

而局部變量表中的變量不存在這種賦“零”值的階段。

所以開發者必須給局部變量賦初始值。

操作數棧

在之前介紹java內存區域時較詳細的介紹過操作數棧。

操作數棧就是一個臨時的工作區。

操作數棧裏面的元素來自於“局部變量表”。

也就是說,當局部變量表中的局部變量需要進行算法操作時,就將數據壓入操作數棧中然後進行操作。

最後往往會將最終的運算結果先壓入操作數棧,然後彈出放回到局部變量表中。

雖然每個棧幀之間是獨立的,但往往為了優化,會使兩個棧幀“共用一部分操作數棧”,如參數傳遞時。

這樣就不需要額外的參數復制傳遞了。

動態鏈接

和“動態鏈接”相對應的就是“靜態解析”。

這兩個名詞的意義其實都是:“把符號引用轉化成直接引用”。

二者的區別是:

靜態解析:在類加載階段和“第一次使用”時進行轉化。(具體過程可以參考我上面的“類初始化的解析階段”)

動態鏈接:在類的“每一次運行期間”進行轉化。(這屬於jvm對動態類型語言的支持)

方法調用

方法調用和方法執行是不同的,

方法調用階段的唯一任務就是“確定具體調用哪一個方法”。

所謂的“確定具體調用哪一個方法”,就是兩種方式實現的:靜態解析和動態鏈接。(這兩者都可以把符號引用轉化成直接引用,也就是確定具體調用哪一個方法)

解析

先說靜態解析。

class文件的編譯過程,不會將“符號引用”轉化成“直接引用”。(也就是說,class文件中不會確定具體的方法調用)

而在類加載階段才會開始將一部分“符號引用”轉化成“直接引用”,而這個過程就叫靜態解析。

註意:

能夠“靜態解析”的方法需要符合“編譯期可知,運行期不變”的要求,

符合該要求的方法主要包括兩大類:靜態方法和私有方法。(當然不止這兩大類的方法,下面提到的重載方法等,也符合靜態解析的要求)

(換句話說,即靜態方法和私有方法是在類加載過程中靜態解析的)

因為靜態方法和私有方法都不可能通過繼承等方式“重寫成其他版本”。

分派

之前的靜態方法和私有方法全都是靜態解析的。

而其余的方法的“符號引用”轉化成“直接引用”的過程稱之為“分派”。

而分派又分為靜態分派(與靜態解析一樣,是在編譯期間“確定”,然後在類加載時執行靜態解析),和動態分派(在運行期才能完成解析)

靜態分派

靜態分派多表現成方法的“重載”。

方法的“重載”就是:某一類中存在多個方法名相同的方法,但具有不同的參數個數或類型,則在調用這種方法時,稱之為方法重載。

再將靜態分派前,先講一下以下概念:

Human man = new Man();

其中:

Human是變量的“靜態類型”。

Man是變量的“實際類型”。

二者的區別是:

靜態類型在“編譯期”就已經可知且確定了。

而實際類型需要等到運行期才可確定。

我們再來看“重載方法”的調用過程。

重載方法是根據參數的不同來確定具體調用的是那個重載方法。

而傳入參數的類型(也就是靜態類型),是在編譯期就可知的,

也就是說,在編譯期就能夠確定“具體調用的是那個重載方法”。

而這種調用過程,我們稱之為“靜態分派”。

關於重載方法的調用符合靜態解析的要求(即“編譯期可知,運行期不變”)。

所以重載方法的解析,屬於靜態解析,在類加載時就會被解析。

動態分派

動態分派多表現為方法的“重寫”。

方法的重寫就是:子類繼承父類,子類重寫了父類中的某個方法。

還是先從靜態類型和實際類型的角度舉個例子:

(以下例子中Man重寫和Woman都重寫了Human中的sex()方法)

Human human1= new Man();

Human human2= new Woman();

human1.sex();

human2.sex();

通過之前的講解我們可以知道Human是在編譯期可確定的,但這次的sex方法調用明顯跟後面的“實際類型”有關。

所以sex()方法的調用“不能在編譯期確定調用哪一個方法”。

也就是說,重寫方法的調用(動態分派)不符合靜態解析的要求。

動態分派的具體過程如下:

在創建對象的最後一個階段執行,也就是“調用實際的構造方法”階段。

把實際類型(Man)的直接引用先存放入“局部變量表”。

在從局部變量表中的這些實際類型(Man)的直接引用壓入“操作數棧”

然後根據操作數棧中的引用,找到引用所指的實際類型(Man)。

再找該類型(Man)中有沒有需要調用的方法(sex()),如果有,則執行,

否則,繼續找該類(Man)的父類(Human)中有沒有改方法(sex())。

這也就是為什麽:

調用某個子類的重寫方法時,會執行該子類的方法,而不是父類中的方法。

而通過子類又可以調用其父類的方法。

在實際實現中基於性能考慮,大部分虛擬機都不會真正的進行這種復雜的操作。

最常用的優化手段就是:

在方法區中建立一張“虛方法表”,使用虛方法表索引來代替元數據查找。

虛方法表中存放了各個方法的“實際入口”。

如果某父類方法沒有被“重寫”過,則該父類與其子類的“虛方發表”中的該方法的實際入口都是“父類的方法”。

這樣優化的好處是:

如果方法沒被重寫,則不需要先查找子類,再查找父類,而是可以直接找到父類中的該方法。

java虛擬機詳解