天天看點

JVM學習筆記(二)Java虛拟機棧

java虛拟機棧

JVM學習筆記(二)Java虛拟機棧

書上的描述:

  • java虛拟機棧也是線程私有的,它的生命周期與線程相同。虛拟機棧描述的是java方法執行的記憶體模型:每個方法執行的同時都會建立一個棧幀(StackFrame),用于存儲局部變量表、操作數棧、動态連結、方法出口*等資訊。每一個方法的執行就對應着棧幀在虛拟機棧中的入棧,出棧過程。
  • 局部變量表存放編譯期可知的各種基本資料類型、對象引用類型和returnAddress類型。64位長度的long和double類型的資料會占用2個局部變量空間(slot),其餘的資料類型隻占一個。局部變量表所需的記憶體空間在編譯期間完成配置設定,當進入一個方法時這個方法需要在幀重配置設定多大的局部變量空間是完全确定的,方法運作期間不會改變局部變量表的大小。
  • 在java虛拟機規範中,對這個區域規定了兩種異常情況:如果線程請求的棧深度大于虛拟機所允許的深度,将抛出StackOverflowError異常;如果虛拟機棧可以動态擴充,如果擴充時無法申請到足夠的記憶體,就會抛出OutOfMemoryError異常。

棧幀

棧幀(Frame)是用來存儲資料和部分過程結果的資料結構,同時也被用來處理動态連結 (Dynamic Linking)、方法傳回值和異常分派(Dispatch Exception)。

一個線程中的方法調用鍊路可能會很長,很多方法都處于同時執行的狀态。對于執行引擎來說,在活動線程中,隻有處于棧頂的棧幀才是有效的,稱為目前棧幀,與這個棧幀相關聯的方法稱為目前方法。

執行引擎運作的所有位元組碼指令隻針對目前棧幀進行操作,在概念模型上,典型的棧幀結構如圖所示:

JVM學習筆記(二)Java虛拟機棧

局部變量表

在工作和學習過程中,java程式員會把java記憶體分為堆記憶體和棧記憶體,這裡所說的棧就是java虛拟機棧,更準确的說應該是虛拟機棧中的局部變量表。

**局部變量表是一組變量值存儲空間,用以存儲方法參數與方法内部定義的局部變量。**在Java程式被編譯為Class檔案時,就在方法的Code屬性的max_locals資料項中确定了該方法所需的局部變量表的最大容量。

局部變量表的容量以變量槽(VariableSlot,下稱Slot)為最小機關,虛拟機規範中并沒有明确指出一個slot占應用記憶體的大小,隻是很有導向性的指出一個slot都應該可以存放一個byte、short、int、float、char、boolean、對象引用(舉個簡單的例子我們用到的this,後面會提到)、returnAddress(指向一個位元組碼指令的位址),這8種類型的資料,都可以使用32位或者更小的空間去存儲,但這種描述與明确指出“每個slot占用32位的記憶體空間”有一些差別,它允許slot的長度可以随着處理器、虛拟機、作業系統的不同而發生變化。

引申:

JVM學習筆記(二)Java虛拟機棧

在JVM規範的第二版中,Java的三種原始資料類型是數值型、boolean類型、傳回位址類型(returnAddress),這三種是JVM支援的原始類型。

returnAddress該類型是jsr,ret以及jsr_w指令需要使用到的,它的值是JVM指令的操作碼的指針,并且它的值是不能被運作中的程式所修改的。而且因為java中明确的64位的資料類型隻有long、double,(reference類型可能是32,也可能是64位的),是以我們returnAddress是占據一個slot空間的。

一個slot可以存放一個32位的資料類型,Java中占用32位以内的資料類型有byte(8)、short(16)、int(32)、float(32)、char(16)、boolean、reference(對象引用,java虛拟機沒有規定reference類型的長度,它的實際長度與32位還是64位虛拟機有關,如果是64位虛拟機,他的長度還與是否開啟某些對象指針的壓縮優化有關)、returnAddress 8種資料類型。第7種refrence類型表示一個對象執行個體的引用,虛拟機規範中既沒有說明長度也沒有說明引用應有怎樣結構。

**對于64位的資料類型,虛拟機會通過高位補齊的方式為其配置設定兩個連續的slot空間,java中明确的64位的資料類型隻有long、double,(reference類型可能是32,也可能是64位的),**如果通路的是32位資料類型,索引n就代表使用了第n個slot;如果通路的是64位資料類型,索引n就代表使用了第n和n+1個slot。對于兩個相鄰的存放64位資料的slot,不能單獨通路其中一個,java虛拟機規範中明确要求了如果遇到了這種操作的位元組碼序列,虛拟機應該在類加載的校驗階段抛出異常。

虛拟機如何調用這個局部變量表?

局部變量表是有索引的,就像數組一樣。從0開始,到表的最大索引,也就是Slot的數量-1。

要注意的是,方法參數的個數 + 局部變量的個數 ≠ Slot的數量。因為Slot的空間是可以複用的,當pc計數器的值已經超出了某個變量的作用域時,下一個變量不必使用新的Slot空間,可以去覆寫前面那個空間。

例如:讓我們考慮一個具有方法bike()的類示例,然後局部變量數組将如下圖所示:

class Example
{
  public void bike(int i, long l, float f, 
               double d, Object o, byte b)
  {
     return 0;
  } 
}
           
JVM學習筆記(二)Java虛拟機棧

特别地,JVMS7:

On instance method invocation, local variable 0 is always used to pass a reference to the object on which the instance method is being invoked (this in the Java programming language)

手動翻譯:在一個執行個體方法的調用時,局部變量表的第0位是一個指向目前對象的引用,也就是Java裡的this。

在執行方法的時候,虛拟機是使用局部變量表完成參數值到參數變量清單的傳遞過程的,如果執行的是執行個體方法(非static),那局部變量表的第0個slot預設用來傳遞方法所屬對象的引用,在方法中通過this關鍵字可以通路這個隐含的參數。其餘參數按照參數表順序排列,參數表配置設定完畢,再根據方法内部局部變量的順序和作用域配置設定slot。

JVM學習筆記(二)Java虛拟機棧

為了盡可能節省棧幀空間,局部變量表中的slot是可以重用的。方法中定義的變量,其作用域并不一定會覆寫整個方法體,如果目前位元組碼PC計數器的值已經超過了某個變量的作用域,那麼這個變量所在的slot可以交給其他變量使用。不過這樣的設計除了節省棧幀空間以外,還會伴随一些額外的副作用。例如,在某些情況下,slot的複用會直接影響到系統的gc。

探究——局部變量表Slot複用對垃圾收的影響

首先認識System.gc():

System.gc()用于調用垃圾收集器,在調用時,垃圾收集器将運作以回收未使用的記憶體空間。它将嘗試釋放被丢棄對象占用的記憶體。然而System.gc()調用附帶一個免責聲明,無法保證對垃圾收集器的調用。我們習慣了從現實世界的經驗中獲得的“條件适用”。一切都附有免責聲明!

JVM實作者可以通過System.gc()調用來決定JVM的行為。一般來說,我們在編寫Java代碼并将其留給JVM時,不要考慮記憶體管理。在一些特殊情況下,如我們正在編寫一個性能基準,我們可以在運作之間調用System.gc()。以下是調用gc有所作為的另一個例子。

了解一下System.gc()機制:

public class Main{
    public static void main(String [] args){
        byte[] placeholder = new byte[64*1024*1024];
        System.gc();
    }
}
           
JVM學習筆記(二)Java虛拟機棧

對于上面dos輸出的結果,我是這樣了解的:

第一行,Allocation Filure(空間配置設定失敗)引起了Minor GC。因為建立的對象太大,新生代裝不下,是以進行了一次GC。

第二行,由于新生代GC完了後,還是裝不下,這時就應該把它直接放到老年代,為了老年代又足夠的空間來迎接這個大對象,是以老年代進行一次Full GC。

第三行,是代碼中的手動gc,發現這次手動gc并沒有回收掉這個大對象。因為,placeholder這個對象,還在作用域…就不該回收…

這回System.gc()該回收掉placeholder了吧?

public class Main{
    public static void main(String [] args){
        {
            byte[] placeholder = new byte[64*1024*1024];
        }
        System.gc();
    }
}
           

dos結果基本與上面一樣,明顯,還是沒有回收掉這個placeholder大對象。為什麼呢?

因為虛拟機并不急着讓placeholder回收掉,因為,在我這個程式中,對虛拟機來說,回不回收placeholder,對記憶體沒有絲毫影響,剩餘的空間一樣都是浪費(空閑)着,回收了反倒還浪費時間。

這樣做才能成功回收:

public class Main{
    public static void main(String [] args){
        {
            byte[] placeholder = new byte[64*1024*1024];
        }
        int a = 0;
        System.gc();
    }
}
           
JVM學習筆記(二)Java虛拟機棧

其實複用之前,雖然placeholder退出了作用域,但是虛拟機并沒有做什麼事,隻是知道pc指針已經超出了placeholder的作用域,知道placeholder過期了。是以placeholder仍保持者GC Roots之間的關聯。

當a=0複用了前面對象的空間時,就打斷了GC Roots與局部變量表中的placeholder之間的關聯。因為a複用了這片空間(雖然隻是用了一小部分)。此時GC Root無法達到placeholder對象,滿足回收條件。

然後System.gc()就成功回收了。

也就是說在複用之前并不會判定為‘垃圾’,在複用後才會被判定為‘垃圾’。剛才使用一個int a來複用,這個複用看起來很輕量。

如果使用一個新的大對象來複用,那麼GC是如何發生的呢?看下面代碼:

public class Main{
    public static void main(String [] args)throws InterruptedException{
        {
            byte[] placeholder = new byte[64*1024*1024];
        }
        byte[]arr= new byte[20*1024*1024];
        System.gc();
    }
} 
           
JVM學習筆記(二)Java虛拟機棧

解讀dos下的輸出:

第一行,因為即将建立的placeholder太大,新生代裝不下,是以進行一次GC。

第二行, 因為GC之後還是裝不下placeholder,是以把這個大對象直接放進老年代裡。迎接這個大對象之前,先清一清自己的空間(Full GC),怕自己裝不下。

第三行,因為即将建立的arr太大,新生代裝不下,是以進行一次GC。

第四行,因為GC之後還是裝不下arr, 是以把這個大對象直接放進老年代裡。迎接這個大對象之前,先清一清自己的空間(Full GC),怕自己裝不下。

但是,可以看到這一次Full GC并沒有把placeholder清理掉,因為還沒開始複用呢。

随後建立好了arr, 也就是複用了placeholder的空間。這時才把placeholder判定為垃圾。

第五行,是代碼裡手寫的System.gc()方法。這時把placeholder這個垃圾清理掉。

有沒有發現這個Full GC來的不是很恰到好處?因為沒有及時清理掉placeholder。

為什麼沒有清理掉呢?因為局部變量表裡的placeholder資料還和GC Root連着,導緻沒有判定它為垃圾。

能不能及時斷開這個連接配接,讓這個Full GC起到它該起的作用呢?

可以巧用null來解決,看下面代碼:

public class Main{
    public static void main(String [] args)throws InterruptedException{
        {
            byte[] placeholder = new byte[64*1024*1024];
            placeholder = null;
        }
        byte[]arr= new byte[20*1024*1024];
        System.gc();
    }
}
           
JVM學習筆記(二)Java虛拟機棧

解讀dos下的輸出:

第一行,因為即将建立的placeholder太大,新生代裝不下,是以進行一次GC。

第二行, 因為GC之後還是裝不下placeholder,是以把這個大對象直接放進老年代裡。迎接這個大對象之前,先清一清自己的空間(Full GC),怕自己裝不下。

随後placeholder= null;

第三行,因為即将建立的arr太大,新生代裝不下,是以進行一次GC。

第四行,因為GC之後還是裝不下arr, 是以把這個大對象直接放進老年代裡。迎接這個大對象之前,先清一清自己的空間(Full GC),怕自己裝不下。

可以看到這一次Full GC把placeholder清理掉了。

随後建立好了arr,複用了placeholder。

第五行,是代碼裡手寫的System.gc()方法。

最後

對于局部變量,如果是基本類型,會把值直接存儲在棧;如果是引用類型,比如String s = new String(“william”);會把其對象存儲在堆,而把這個對象的引用(指針)存儲在棧。

再如

String s1 = new String(“william”);

String s2 = s1;

s1和s2同為這個字元串對象的執行個體,但是對象隻有一個,存儲在堆,而這兩個引用存儲在棧中。

延伸——分代收集算法

分代搜集算法是針對對象的不同特性,而使用适合的算法,這裡面并沒有實際上的新算法産生。與其說分代搜集算法是第四個算法,不如說它是對前三個算法(标記-清除算法、複制算法、标記-整理算法)的實際應用。分代搜集算法根據對象的存活周期的不同而将記憶體分為幾塊,分别為新生代、老年代和永久代。

對象分類

  • 新生代:朝生夕滅的對象(例如:方法的局部變量等)。
  • 老年代:存活得比較久,但還是要死的對象(例如:緩存對象、單例對象等)。
  • 永久代:對象生成後幾乎不滅的對象(例如:加載過的類資訊)。

記憶體區域

新生代和老年代都在java堆,永久代在方法區。

java堆對象的回收

現在,我們來看看分代收集算法是如何針對堆記憶體進行回收的。

新生代:采用複制算法,新生代對象一般存活率較低,是以可以不使用50%的記憶體作為空閑,一般的,使用兩塊10%的記憶體

作為空閑和活動區間,而另外80%的記憶體,則是用來給建立對象配置設定記憶體的。一旦發生GC,将10%的活動區間與另外80%中存

活的對象轉移到10%的空閑區間,接下來,将之前90%的記憶體全部釋放,以此類推,下面還是用一張圖來說明:

JVM學習筆記(二)Java虛拟機棧

年輕代(Young Generation):對象被建立時,記憶體的配置設定首先發生在年輕代(大對象可以直接 被建立在年老代),大部分的對象在建立後很快就不再使用,是以很快變得不可達,于是被年輕代的GC機制清理掉(IBM的研究表明,98%的對象都是很快消 亡的),這個GC機制被稱為Minor GC或叫Young GC。注意,Minor GC并不代表年輕代記憶體不足,它事實上隻表示在Eden區上的GC。

年輕代上的記憶體配置設定是這樣的,年輕代可以分為3個區域:Eden區(伊甸園,亞當和夏娃偷吃禁果生娃娃的地方,用來表示記憶體首次配置設定的區域,再貼切不過)和兩個存活區(Survivor 0 、Survivor 1)。

第一點是使用這樣的方式,我們隻浪費了10%的記憶體,這個是可以接受的,因為我們換來了記憶體的整齊排列與GC速度。第二點是,這個政策的前提是,每次存活的對象占用的記憶體不能超過這10%的大小,一旦超過,多出的對象将無法複制。

解釋下,堆大小=新生代+老年代,新生代與老年代的比例為1:2,新生代細分為一塊較大的Eden空間和兩塊較小的Survivor空間,分别被命名為from和to。

老年代:老年代中使用“标記-清除”或者“标記-整理”算法進行垃圾回收,回收次數相對較少,每次回收時間比較長。

詳細過程:

https://blog.csdn.net/hp910315/article/details/50985877

補充:上面隻是說了年限過大放入年老代。在新生代存活對象占用的記憶體超過10%時,則多餘的對象會放入年老代。這種時候,年老代就是新生代的“備用倉庫”。

一個對象的這一輩子

我是一個普通的Java對象,我出生在Eden區,在Eden區我還看到和我長的很像的小兄弟,我們在Eden區中玩了挺長時間。有一天Eden區中的人實在是太多了,我就被迫去了Survivor區的“From”區,自從去了Survivor區,我就開始漂了,有時候在Survivor的“From”區,有時候在Survivor的“To”區,居無定所。直到我18歲的時候,爸爸說我成人了,該去社會上闖闖了。于是我就去了年老代那邊,年老代裡,人很多,并且年齡都挺大的,我在這裡也認識了很多人。在年老代裡,我生活了20年(每次GC加一歲),然後被回收。

方法區對象回收

永久代指的是虛拟機記憶體中的方法區,永久代垃圾回收比較少,效率也比較低,但也必須進行垃圾回收,否則永久代記憶體不夠用時仍然會抛出OutOfMemoryError異常。永久代也使用“标記-清除”或者“标記-整理”算法進行垃圾回收。

回收的時機

JVM在進行GC時,并非每次都對上面三個記憶體區域一起回收的,大部分時候回收的都是指新生代。是以GC按照回收的區域又分了兩種類型,一種是普通GC(minor GC),一種是全局GC(major GC or Full GC),它們所針對的區域如下。

普通GC(minor GC):隻針對新生代區域的GC。

全局GC(major GC or Full GC):針對所有分代區域(新生代、年老代、永久代)的GC。

由于年老代與永久代相對來說GC效果不好,而且二者的記憶體使用增長速度也慢,是以一般情況下,需要經過好幾次普通GC,才會觸發一次全局GC。

标記-清除算法

标記-清除(Mark-Sweep)算法是最基礎的算法,就如它的名字一樣,算法分為”标記”和”清除”兩個階段:首先标記出所有需要回收的對象,在标記完成後統一回收掉所有被标記的對象。之是以說它是最基礎的收集算法,是因為後續的收集算法都是基于這種思路并對其缺點進行改進而得到的。它主要有兩個缺點:一個是效率問題,标記和清楚過程的效率都不高;另外一個是空間問題,标記清楚後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻,當程式在以後的運作過程中需要配置設定較大對象時無法找到足夠連續的記憶體空間而不得不提前出發另一次垃圾收集動作。

JVM學習筆記(二)Java虛拟機棧
JVM學習筆記(二)Java虛拟機棧

複制算法

為了解決效率問題,一種稱為複制(Copying)的收集算法就出現了,它将可用記憶體按容量劃分為大小相等的兩塊,每次隻是用其中一塊。當這一塊的記憶體用完了,就将還存活着的對象複制到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對其中的一塊進行記憶體回收,沒存配置設定時也就不用考慮記憶體碎片等複雜情況,隻要移動堆頂指針,按順序配置設定記憶體即可,實作簡單,運作高效。隻是這種算法的代價是将運存縮小為原來的一半,未免太高了一點。

JVM學習筆記(二)Java虛拟機棧
JVM學習筆記(二)Java虛拟機棧

标記-整理算法

複制手機算法在對象存活率較高時就要執行較多的複制操作,效率将會貶低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行配置設定擔保,以應對被使用的記憶體中所有對象都100%存活的極端情況,是以在老年代一般不能直接選用這種算法。

根據老年代的特點,有人提出了另外一種”标記-整理”算法,标記過程仍然與标記-清楚算法一樣,但是後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界意外的記憶體。

JVM學習筆記(二)Java虛拟機棧
JVM學習筆記(二)Java虛拟機棧

關于局部變量表,還有一點要注意,可能會影響開發的,就是他不存在類變量和執行個體變量那樣的準備階段,不存在初始值,在使用之前,必須要給值。在使用前,不給值,這段代碼其實并不能運作,還好編譯器能在編譯期間就檢查到并提示這一點,即使編譯能通過或者手動生成位元組碼的方法制造出下面代碼的效果,位元組碼校驗的時候也會被虛拟機發現而導緻類加載失敗。

JVM學習筆記(二)Java虛拟機棧
JVM學習筆記(二)Java虛拟機棧

引申:

類變量(靜态變量)和執行個體變量:

類變量也稱為靜态變量,在類中以static關鍵字聲明,但必須在方法構造方法和語句塊之外;執行個體變量聲明在一個類中,但在方法、構造方法和語句塊之外。類變量(靜态變量)和執行個體變量都屬于成員變量。

空間配置設定的時間不同

類變量是在類加載後的準備階段在方法區配置設定記憶體的。執行個體變量是在類執行個體化為對象的時候在堆中配置設定記憶體。

初始化

類變量在準備階段會進行預設初始化,當某些條件滿足時候會觸發類的初始化。執行個體變量在空間配置設定記憶體後,虛拟機會将所配置設定到的記憶體空間都初始化為零值(不包括對象頭)。這一步操作保證了對象的執行個體字段在java代碼中可以不賦初值就可以直接通路,程式能通路到這些字段的資料類型所對應的零值。

對于局部變量,隻能顯示地進行初始化,否則不能通路該變量的值。

操作數棧

前面我們講到了局部變量表,局部變量表中的變量不可直接使用,如需使用必須通過相關指令将其加載至操作數棧中作為操作數使用.

什麼是操作數棧

每個棧幀都包含一個被叫做操作數棧的後進先出的棧。叫操作棧,或者操作數棧。通常情況下,操作數棧指的就是目前棧桢的操作數棧。

操作數棧有什麼用?

1.棧桢剛建立時,裡面的操作數棧是空的。

2.Java虛拟機提供指令來讓操作數棧對一些資料進行入棧操作,比如可以把局部變量表裡的資料、執行個體的字段等資料入棧。

3.同時也有指令來支援出棧操作。

4.向其他方法傳參的參數,也存在操作數棧中。

5.其他方法傳回的結果,傳回時存在操作數棧中。

例子操作數棧的使用:

下面是JVM如何使用下面的代碼,它将減去兩個包含兩個int的局部變量,并将int結果存儲在第三個局部變量中:

JVM學習筆記(二)Java虛拟機棧

是以,前面兩條指令iload_0和iload_1将從本地變量數組中推入操作數堆棧中的值。并且指令isub會将這兩個值相減并将結果存回操作數堆棧,在istore_2之後,結果将從操作數堆棧中彈出并存儲到位置2處的局部變量數組中。

JVM學習筆記(二)Java虛拟機棧

操作數棧本身就是一個普通的棧嗎?

其實棧就是棧,再加上資料結構所支援的一些指令和操作。

但是,這裡的棧也是有限制的。

操作數棧是區分類型的,操作數棧中嚴格區分類型,而且指令和類型也好嚴格比對。

棧桢和棧桢是完全獨立的嗎?

本來棧桢作為虛拟機棧的一個單元,應該是棧桢之間完全獨立的。

但是,虛拟機進行了一些優化:為了避免過多的 方法間參數的複制傳遞、方法傳回值的複制傳遞 等一些操作,就讓一部分資料進行棧桢間共享。這種在進行方法調用時,可以共用一部分資料,無須進行額外的參數複制傳遞,java虛拟機的解釋執行引擎被稱為 基于棧的執行引擎,其中的棧就是操作數棧。

JVM學習筆記(二)Java虛拟機棧

動态連結

什麼是動态連結?

一個方法調用另一個方法,或者一個類使用另一個類的成員變量時,總得知道被調用者的名字吧?(你可以不認識它本身,但調用它就需要知道他的名字)。符号引用就相當于名字,這些被調用者的名字就存放在Java位元組碼檔案裡。

名字是知道了,但是Java真正運作起來的時候,真的能靠這個名字(符号引用)就能找到相應的類和方法嗎?

需要解析成相應的直接引用,利用直接引用來準确地找到。

舉個例子,就相當于我在0X0300H這個位址存入了一個數526,為了友善程式設計,我把這個給這個位址起了個别名叫A, 以後我程式設計的時候(運作之前)可以用别名A來暗示通路這個空間的資料,但其實程式運作起來後,實質上還是去尋找0X0300H這片空間來擷取526這個資料的。

這樣的符号引用和直接引用在運作時進行解析和連結的過程,叫動态連結。

延伸——靜态連結

上面我們知道了:動态連結的方式,即用到某個類再加載進記憶體。那靜态連結呢,像C++那樣使用靜态連結:将所有類加載,不論是否使用到.

在Class檔案中的常量持中存有大量的符号引用。位元組碼中的方法調用指令就以常量池中指向方法的符号引用作為參數。這些符号引用一部分在類的加載階段(解析)或第一次使用的時候就轉化為了直接引用(指向資料所存位址的指針或句柄等),這種轉化稱為靜态連結。而相反的,另一部分在運作期間轉化為直接引用,就稱為動态連結。

方法調用及分派:有時間後面進行分享,因為要了解類檔案結構才能更好的講解。

方法傳回位址

當一個方法被執行後,有兩種方式退出這個方法。第一種方式是執行引擎遇到任意一個方法傳回的位元組碼指令,這時候可能會有傳回值傳遞給上層的方法調用者(調用目前方法的方法稱為調用者),是否有傳回值和傳回值的類型将根據遇到何種方法傳回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。

另外一種退出方式是,在方法執行過程中遇到了異常,并且這個異常沒有在方法體内得到處理,無論是Java虛拟機内部産生的異常,還是代碼中使用athrow位元組碼指令産生的異常,隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者産生任何傳回值的。

無論采用何種退出方式,在方法退出之後,都需要傳回到方法被調用的位置,程式才能繼續執行,方法傳回時可能需要在棧幀中儲存一些資訊,用來幫助恢複它的上層方法的執行狀态。一般來說,方法正常退出時,調用者的PC計數器的值就可以作為傳回位址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,傳回位址是要通過異常處理器來确定的,棧幀中一般不會儲存這部分資訊。

方法退出的過程實際上等同于把目前棧幀出棧,是以退出時可能執行的操作有:恢複上層方法的局部變量表和操作數棧,把傳回值(如果有的話)壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等。

附加資訊

虛拟機規範允許具體的虛拟機實作增加一些規範裡沒有描述的資訊到棧幀之中,例如與調試相關的資訊,這部分資訊完全取決于具體的虛拟機實作,這裡不再詳述。在實際開發中,一般會把動态連接配接、方法傳回位址與其他附加資訊全部歸為一類,稱為棧幀資訊。

最後這張圖給大家了解一下整個過程

JVM學習筆記(二)Java虛拟機棧

兩種異常情況

虛拟機棧的StackOverflowError

若單個線程請求的棧深度大于虛拟機允許的深度,則會抛出StackOverflowError(棧溢出錯誤)。

JVM會為每個線程的虛拟機棧配置設定一定的記憶體大小(-Xss參數),是以虛拟機棧能夠容納的棧幀數量是有限的,若棧幀不斷進棧而不出棧,最終會導緻目前線程虛拟機棧的記憶體空間耗盡,典型如一個無結束條件的遞歸函數調用,代碼見下:

/**
 * java棧溢出StackOverFlowError
 * JVM參數:-Xss128k
 */
public class JavaVMStackSOF {

    private int stackLength = -1;

    //通過遞歸調用造成StackOverFlowError
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack length:" + oom.stackLength);
            e.printStackTrace();
        }
    }
}
           

設定單個線程的虛拟機棧記憶體大小為128K,執行main方法後,抛出了StackOverflow異常

Stack length:19721
java.lang.StackOverflowError
	at code/xuesheng.stackLeak(xuesheng.java:10)
           

虛拟機棧的OutOfMemoryError

  不同于StackOverflowError,OutOfMemoryError指的是當整個虛拟機棧記憶體耗盡,并且無法再申請到新的記憶體時抛出的異常。

  JVM未提供設定整個虛拟機棧占用記憶體的配置參數。虛拟機棧的最大記憶體大緻上等于“JVM程序能占用的最大記憶體(依賴于具體作業系統) - 最大堆記憶體 - 最大方法區記憶體 - 程式計數器記憶體(可以忽略不計) - JVM程序本身消耗記憶體”。當虛拟機棧能夠使用的最大記憶體被耗盡後,便會抛出OutOfMemoryError,可以通過不斷開啟新的線程來模拟這種異常,代碼如下:

/**
  * java棧溢出OutOfMemoryError
  * JVM參數:-Xss2m
  */

public class JavaVMStackOOM {

    private void dontStop() {
        while (true) {
        }
    }

    //通過不斷的建立新的線程使Stack記憶體耗盡
    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(() -> dontStop());
            thread.start();
        }
    }

    public static void main(String[] args) {
        JavaVMStackOOM oom = new _03_JavaVMStackOOM();
        oom.stackLeakByThread();
    }

}
           

設定單個線程虛拟機棧的占用記憶體為2m并不斷生成新的線程,最終虛拟機棧無法申請到新的記憶體,抛出異常:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
           

特别提示一下,如果讀者要嘗試運作上面這段代碼,記得要先儲存目前的工作,由于在Windows平台的虛拟機中,Java的線

程是映射到作業系統的核心線程上的,是以上述代碼執行時有較大的風險,可能會導緻作業系統假死。