【死磕JVM】一道面試題引發的“棧幀”!!!


前言

最近小農的朋友——小勇在找工作,開年來金三銀四,都想跳一跳,找個踏(gao)實(xin)點的工作,這不小勇也去面試了,不得不說,現在面試,各種底層各種原理,層出不窮,小勇就遇上了這么一道面試題,因為沒有回答好,面試被PASS,讓他備受打擊,作為大(lao)哥(si)哥(ji)的我,肯定要安慰一下,到底是什么樣的面試題,讓小勇又一次夭折在面試的路上,好奇怪為什么要說又?簡直讓人喜極而泣,哈哈哈,言歸正傳,我們一起來看一下!

話說小勇正襟危坐在面試官面前,這已經是小勇的第五次面試了,前幾次都是石沉大海,讓小勇有點着急了,但是小勇這一次可是有備而來,之前面試不會的問題,大部分都狠狠的補習了一下,想來這一次問題應該不大。

前面基礎問題小勇都回答的有模有樣的,面試官一看,基礎還算可以,問一點有深度的吧!

面試官:我看你簡歷上寫的熟悉JVM,我給你下面一個題目,先來講一講a = a ++; 和a = ++a; 的運行結果各是多少?

public class Test1 {
    public static void main(String[] args) {
        int a = 88;
        a = a++;
//        a = ++a;
        System.out.println(a);
    }
}

小勇心想:這不是小菜一碟嗎,這我能不知道?
於是小勇輕蔑一笑說:a = a++; 輸出結果是 8 ,a = ++a; 是 9
心想我還以為多有難度呢,就這?這種題目給我再來一個吧!

面試官:無動於衷,面無表情的說道,為什么結果是這樣的,你知道嗎?

小勇:還真來,提高難度了,小樣有點東西啊,還好准備了,不然今天就在你這道題上坑住了。
a++ 是先計算 a 在++,在分號結束的才會做a++運算,所以當我們做賦值操作的時候a++ 還是 8,所以賦值給a的時候也是8,只有當分號結束了a++才會是9
++a 是 先計算 ++a ,不管是否在分號結束,這個時候的值就已經是 9 了,所以賦值的時候,a就變成了9,輸出結果也就是9了
這下沒話說了吧!

面試官摸了一下下巴,緩緩說到:這個操作在JVM內存里面是怎樣運行的?

小勇:怎么運行的,這個不是底層原理了嗎?劇本不是這么發展的,這塊沒有了解過。。。。
小勇:支支吾吾說道,這個沒有了解過,不太清楚底層的實現

面試官輕蔑一笑說:行,今天面試就先到這里了,有什么事情,人事會通知你的!

小勇:!$%@#&*

不懂就學

聽到上面小勇所講的東西之后,大概了解到,面試官應該是要考他關於運行時數據在內存時候的知識點,不懂就學,遇到事情不要慌,想要真正理解上面的面試題的精髓,我們要做一些前置知識的點綴,首先我們先來看看下面一張圖:
類生命周期:
在這里插入圖片描述

上圖中首先將.class 文件讀取到內存,存放在方法區(Perm Gen), 最終產品是Class對象,然后檢查是否有正確數據結構,JVM為Class的靜態變量分配內存,並設置默認初始值,把Class的二進制數據中的符號引用替換為直接引用,JVM為執行Class 的static 語句塊,會先初始化其父類,跑到JVM虛擬機之后呢,會進入到運行時引擎,最后在運行時引擎里面運行,運行的時候在內存里面是一個什么樣的情況,這個就是我們要講的重點——run-time data areas

運行時數據區

Java虛擬機運行時數據區:
在這里插入圖片描述

1.1 程序計數器

> 程序計數器是一塊較小的內存空間, 它可以看作是當前線程所執行的字節碼的行號指示器。由於Java虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器都會只執行一條線程中的指令,因此為了線程切換后都能回復正確的執行位置,每個線程都有一個獨立的程序計數器。如果線程正在執行的是一個java方法,這個計數器記錄的就是正在執行的虛擬機字節碼指令的地址。如果正在執行的是native方法,這個計數器值則為空。

作用:
1、字節碼解釋器通過改變程序計數器來一次讀取指令,從而實現代碼的流程控制。比如:順序執行、選擇、循環、異常處理等
2、在多線程的情況下,程序計數器用於記錄當前線程線程執行的位置,當線程被切換回來的時候能夠知道該線程上次運行到哪里了

特點:

  1. 是一塊較小的內存空間
  2. 線程私有,每一條線程都有一個程序計數器
  3. 是唯一不會出現 OutOfMemoryError的內存區域
  4. 生命周期隨着線程的創建而創建,隨着線程的結束而結束

1.2 Java虛擬機棧

Java虛擬機棧也是線程私有的,它的生命周期與線程相同,虛擬機棧描述的是Java方法執行的內存模型;每個方法在執行的同時都會創建一個棧幀(stack frame) 用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用至執行完成的過程,就對應着一個棧幀在虛擬機中入棧到出棧的過程。

在這里插入圖片描述

我們結合一個案例來看一下:

public class TestStack {

    public static void main(String[] args) {
            new PlayRice().print();
    }
}

class PlayRice{

    public void fun(){
        System.out.println("干飯人,干飯魂,干飯都是人上人!!!");
    }

    public void print(){
        fun();
    }
}

在這里插入圖片描述

經常有人把Java 內存區域籠統的划分成堆內存(Heap)和棧內存(Stack),這種划分方式是直接繼承自傳統的 C、C++程序的內部結構,但是在Java語言里面顯然是不合適的,Java的內存區域過分要比這兩個更復雜,不過這種划分方式的流行也簡潔說明了程序員最關注的、對象內存分配關系最密切的區域是 堆和棧,棧通常是指虛擬機,或者更多情況下只是指 虛擬機棧中的局部變量表的部分
局部變量表存放了編譯期可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)、對象引用

在《Java虛擬機規范中》,對這個區域規定了兩種異常狀況:
1. 如果線程請求的棧深度大於虛擬機所允許的深度,將拋出 StackOverflowError
2. 如果Java虛擬機棧可以動態擴展,當擴展時無法申請到足夠的內存,就會拋出OutOfMemoryError異常

1.3 本地方法棧

本地方法棧(Native Method Stack)和虛擬機棧所發揮的作用是非常相似的,他們之間的區別就是虛擬機棧為虛擬機執行的Java方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native方法服務。

在虛擬機規范中對本地方法棧中方法使用的語言、使用方式與數據結構並沒有強制規定,因此具體的虛擬機可以自由實現它,甚至有的Java虛擬機(Hot-Spot虛擬機)直接就把本地方法棧和虛擬機棧合二為一。與虛擬機一樣,本地方法棧也會拋出 StackOverflowError 和 OutOfMemoryError 異常。

1.4 堆

Java堆是虛擬機所管理中內存最大的一塊。Java堆是被所有線程共享的一個內存區域,在虛擬機啟動時創建。這個內存區域的唯一目的就是存放對象的實例,Java世界里 幾乎 所有的對象實例都在這里分配。

在《Java虛擬機規范》中對Java堆的描述是:“所有的對象實例以及數組都應當在堆上分配”。Java對是垃圾收集器管理的內存區域。從回收內存的角度看,現代的垃圾收集器大部分都是分代收集理論設計的,所以Java堆中經常會出現 “新生代、老年代、永久代、Eden、Survivor”。

根據《Java虛擬機規范》的規定,Java堆可以處在物理上不連續的內存空間中,但在邏輯上它應該被視為連續的,這點就像我們用磁盤空間去存儲文件一樣,並不要求每個文件都連續存放。但對於大對象(典型的如數組對象),多數虛擬機實現出於實現簡答、存儲高效的考慮,很可能會要求連續的內存空間。

Java堆既可以被實現成固定大小的,也可以是可擴展的,不過當前主流的Java虛擬機都是按照可擴展來實現的(通過參數-Xmx和-Xms設定)。如果在Java堆中沒有內存完成實例分配,並且堆也無法再擴展時,Java虛擬機會拋出OutOfMemoryError異常。

1.5 方法區

方法區(Method Area)和Java堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯后的代碼緩存等數據。雖然《Java虛擬機規范》中把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 “非堆”(Non-Heap),目的是與Java堆區分開來。

《Java虛擬機規范》對方法區的約束是非常高寬松的,除了和Java堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,甚至還可以選擇不實現垃圾收集,所以垃圾收集的行為在這個區域就會比較少出現。這個區域的內存回收目標主要是針對常量池的回收和類型的卸載,但是這個區域的回收效果就比較差強人意了。

如果方法區無法滿足新的內存分配需求的時候,就會拋出 OutOfMemoryError異常。

1.6 運行時常量池

運行時常量池(Runtime Constant Pool)是方法區的一部分。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池表(Constant Pool Table),用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類加載后存放到方法區的運行時常量池中。

Java虛擬機對於Class文件每一部分(包括常量池)的格式都有嚴格規定,如每一個字節用於存儲哪種數據都必須符合規范上的要求才會被虛擬機認可、加載和執行,但對於運行時常量池,《Java虛擬機規范》並沒有任何細節的要求,不同提供商實現的虛擬機可以按照自己的需要來實現,這個內存區域,不過一般來說,除了保存Class文件描述的符號引用外,還會把符號引用翻譯出來的直接引用也存儲在運行時常量池中

運行時常量池相對於Class文件常量池的另外一個重要特征是具備動態性,Java語言並不要求常量一定只有編譯器才能產生,也就是說,並非預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也可以將新的常量放入池中,這種特性被開發人員利用的比較多就是String類的intern()方法。

既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存 時會拋出OutOfMemoryError異常。

1.7 直接內存

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是《Java虛擬機規范》中定義的內存區域。但是這部分也被頻繁的使用過,而且也有可能會導致OutOfMemoryError異常出現,在JDK1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩沖區(Buffer)的I/O方式,它可以使用Native函數庫直接分配堆外內存。然后通過一個存儲在Java堆里面的DirectByteBuffer對象作為這塊內存的引用進行操作。

1.8 小結

從下面一張圖我們就可以看出,每一個線程都有自己的程序計數器、Java虛擬機棧以及本地方法棧,但是他們共享的是堆以及方法區,為什么每個線程都有自己的程序計數器?我們在上面已經講過,就是當一個線程執行完了,CPU切換到另一個線程去執行,當另外一個線程執行完成之后切回來的時候,能夠知道當前線程執行的位置。

在這里插入圖片描述

理解面試題

我們回到最開始我們講的面試題,我們先來看 i=i++等於8,具體他內部是怎樣執行的呢,我們需要看它的指令是怎么操作的
我們可以用過 Jclasslib來解析他二進制碼之后點到的main方法

1.1 安裝 Jclasslib

首先我們需要安裝 Jclasslib,安裝成功如下圖所示:
在這里插入圖片描述

1.2 查看字節碼

首先我們需要 運行main方法 ,加載其class的內容后,點擊 view -> show Bytecode With Jclasslib
在這里插入圖片描述

在這里插入圖片描述
main方法里面記錄的有兩張表:

表1:LineNumberTable 記錄是行號
表2:LocalVariabletable 是局部變量表,里面就是方法內部使用到的變量,第一個是 args ,第二個是a,所以局部變量表,指的就是我們當前這個方法,這個棧幀里面用到了哪些局部變量。

在這里插入圖片描述

a = a++;

接下來我們來看一下,a = a++;中間的執行過程具體是怎么樣的

 0 bipush 88
 2 istore_1
 3 iload_1
 4 iinc 1 by 1
 7 istore_1
 8 getstatic #2 <java/lang/System.out>
11 iload_1
12 invokevirtual #3 <java/io/PrintStream.println>
15 return

如果我們不理解指令具體是什么意思,我們可以點擊對應指令,瀏覽器直接定位這條指令的詳細說明

首先我們來看一下 bipush 88 和 istore_1,對應的是 int a = 88;iload+1 等於89,再把89賦值出來還是89,

  • bipush 88 是指 push byte 放到棧中,88當成一個byte值,會自動擴展成Int類型,把它放到棧中,88放在局部變量表,輸入結果是88。

  • 第二條指令istore_1是把我們棧頂上的那個數出棧,放到下標值為1的局部變量表。局部變量表下標值為1的就是a的值,剛才88是放到棧頂上的,現在把88彈出來放到a里面,所以這兩句話完成之后對應的int a = 88就完成了,如下圖所示

在這里插入圖片描述

  • iload_1: 的意思是 從局部變量加載int(load int from local variable) ,就是從局部變量表中 拿值,之后放到棧里面,如下圖所示:
    在這里插入圖片描述

  • iinc 1 by 1: 執行 a++ 操作,將局部變量表中 數值為88的進行+1 操作,所以就是 89了,
    在這里插入圖片描述

istore_1: 執行 a = a++ 操作,原先已經執行了 a++ 操作,這個時候將 a++ 中 a 賦值給 int a ,所以會將棧中的數據賦值到 局部變量表中,所以這個時候局部變量表中的數據就是88了

在這里插入圖片描述
所以我們最后的結果就是88

a = ++a;

字節碼指令:

 0 bipush 88
 2 istore_1
 3 iinc 1 by 1
 6 iload_1
 7 istore_1
 8 getstatic #2 <java/lang/System.out>
11 iload_1
12 invokevirtual #3 <java/io/PrintStream.println>
15 return

bipush 88和istore_1: 這句話其實完成了 int a = 88,先將88壓棧,然后在出棧賦值到局部變量表中
在這里插入圖片描述
iinc 1 by 1: 進行++a 操作,所以這個時候局部變量表中的數據就變成了89
在這里插入圖片描述

iload_1: 這個時候將局部變量表中的數值壓到棧中,
在這里插入圖片描述

istore_1: 這個時候做 a = ++a 操作,將 a的值賦值給 int a,因為在棧中的數據本身就是89,所以最后打印出來的結果就是89
在這里插入圖片描述

補充:
當我們設置 int a = 250 的時候,下面的值會變成 sipush,是因為 250已經超過127,他已經超過byte 所能代表的最大結果,所以看到的二進制就是sipush,s 代表 short

0 sipush 250

總結

到這里,你學廢了嗎?其實有時候我們學東西,知道怎么用,但是具體里面的細節,就需要我們仔細的去琢磨,有時候會很枯燥,當我們了解其原理之后,會有豁然開朗的感覺嗎?小農會有,你們呢?

我是牧小農,怕什么真理無窮,進一步有進一步的歡喜,大家加油!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM