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

牧小農發表於2021-03-15

前言

最近小農的朋友——小勇在找工作,開年來金三銀四,都想跳一跳,找個踏(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

總結

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

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

相關文章