【運行時數據區】——程序計數器、虛擬機棧

1、運行時數據區

1.1 概述

內存是很是重要的系統資源,是硬盤和CPU的中間倉庫及橋樑,承載着操做系統和應用程序的實時運行。JVM內存佈局規定了Java在運行過程當中內存申請、分配、管理的策略,保證了JVM的高效穩定運行。不一樣的JVM對於內存的劃分方式和管理機制存在着部分差別。結合JVM虛擬機規範,來探討一下經典的JVM內存佈局。html

JVM 內存共分爲本地方法棧、程序計數器、虛擬機棧、堆、方法區五個部分。這些區域有各自的用途和建立與銷燬的時間。有的區域隨着虛擬機進程的啓動而一直存在,有些區域則是依賴用戶線程的啓動和結束而創建和銷燬。java

在上圖中,灰色部分爲線程隔離的數據區域,其餘部分爲線程共享的區域。程序員

運行時數據區 是否線程共享 是否存在內存溢出 是否存在GC
本地方法棧
虛擬機棧
程序計數器
堆區
方法區

1.2 JVM系統線程

JVM容許一個應用有多個線程並行的執行。在Hotspot JVM裏,每一個線程都與操做系統的本地線程直接映射。shell

  • 當一個Java線程準備好執行之後,此時一個操做系統的本地線程也同時建立。Java線程執行終止後,本地線程也會回收。操做系統負責全部線程的安排調度到任何一個可用的CPU上。一旦本地線程初始化成功,它就會調用Java線程中的run()方法。若是使用jconsole或者是其餘調試工具,都能看到在後臺有許多線程在運行。這些後臺線程不包括調用public static void main(String[ ] args)的main線程以及全部main線程建立的線程。

這些後臺系統線程在Hotspot JVM裏主要是如下幾個:數組

  • 虛擬機線程:這種線程的操做是須要JVM達到安全點纔會出現。這些操做必須在不一樣的線程中發生的緣由是他們都須要JVM達到安全點,這樣堆纔不會變化。這種線程的執行類型包括"stop-the-world"的垃圾收集,線程棧收集,線程掛起以及偏向鎖撤銷。
  • 週期任務線程:這種線程是時間週期事件的體現(好比中斷),他們通常用於週期性操做的調度執行。
  • GC線程:這種線程對在JVM裏不一樣種類的垃圾收集行爲提供了支持。
  • 編譯線程:這種線程在運行時會將字節碼編譯成到本地代碼。
  • 信號調度線程:這種線程接收信號併發送給JVM,在它內部經過調用適當的方法進行處理。

2、程序計數器

2.1 概述

程序計數器(Program Counter Register)是一塊較小的內存空間,是運行速度最快的存儲區域。它能夠看做是當前線程所執行的字節碼的行號指示器。Register的命名源於CPU的寄存器,存儲指令相關的現場信息。這裏,並不是指廣義上所指的物理寄存器,或許將其翻譯爲PC寄存器(或指令計數器)會更加貼切(也稱爲程序鉤子)。JVM中的程序計數器是對物理PC寄存器的一種抽象模擬緩存

2.2 做用

程序計數器用來存放下一條指令的地址(將要執行的字節碼指令地址)。在JVM規範中,每一個線程都有它本身的程序計數器,是線程私有的,生命週期與線程的生命週期保持一致。安全

任什麼時候間一個線程都只有一個方法在執行,也就是所謂的當前方法。程序計數器會存儲當前線程正在執行的Java方法的JVM指令地址;或者,若是是在執行native方法,則是未指定值(undefned)。bash

它是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都須要依賴這個計數器來完成。字節碼解釋器工做時就是經過改變這個計數器的值來選取下一條須要執行的字節碼指令。多線程

程序計數器中不存在內存溢出。架構

代碼演示

public class PCRegisterTest {
    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;
    }
}

而後將代碼進行編譯成字節碼文件,查看 發如今字節碼的左邊有一個行號標識,它其實就是指令地址

0 bipush 10
 2 istore_1
 3 bipush 20
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return

經過程序計數器,咱們就能夠知道當前程序執行到哪一步了 。

使用程序計數器存儲字節碼指令地址有什麼用呢?

由於CPU須要不停的切換各個線程,在線程切換回來之後,就得知道接着從哪開始繼續執行。JVM的字節碼解釋器就須要經過改變程序計數器的值來明確下一條應該執行什麼樣的字節碼指令。

PC寄存器爲何被設定爲線程私有的?

多線程在一個特定的時間段內只會執行其中某一個線程的方法,CPU會不停地作任務切換,這樣必然致使常常中斷或恢復,如何保證分毫無差呢?爲了可以準確地記錄各個線程正在執行的當前字節碼指令地址,最好的辦法天然是爲每個線程都分配一個PC寄存器,這樣一來各個線程之間即可以進行獨立計算,從而不會出現相互干擾的狀況。

因爲CPU時間片輪限制,衆多線程在併發執行過程當中,任何一個肯定的時刻,一個處理器或者多核處理器中的一個內核,只會執行某個線程中的一條指令。

這樣必然致使常常中斷或恢復,如何保證分毫無差呢?每一個線程在建立後,都會產生本身的程序計數器和棧幀,程序計數器在各個線程之間互不影響。

CPU時間片

CPU時間片即CPU分配給各個程序的時間,每一個線程被分配一個時間段,稱做它的時間片。

在宏觀上:能夠同時打開多個應用程序,每一個程序並行不悖,同時運行。

但在微觀上:一個CPU一次只能處理程序要求的一部分。如何處理公平,一種方法就是引入時間片,每一個程序輪流執行。

3、虛擬機棧

3.1 概述

因爲跨平臺性的設計,Java的指令都是根據棧來設計的(因爲不一樣平臺CPU架構不一樣,因此基於寄存器設計)。

  • 優勢:跨平臺,指令集小,編譯器容易實現。
  • 缺點:性能降低,實現一樣的功能的指令更多。

常有人把Java內存區域籠統地劃分爲堆內存(Heap)和棧內存(Stack),這種劃分方式直接繼承自傳統的C、C++程序的內存佈局結構,在Java語言裏就顯得有些粗糙了,實際的內存區域劃分要比這更復雜。「棧」一般就是指這裏講的虛擬機棧,能夠明確的是——棧是運行時的單位,而堆是存儲的單位。

  • 棧解決程序的運行問題,即程序如何執行,或者說如何處理數據。
  • 堆解決的是數據存儲的問題,即數據怎麼放,放哪裏。

那麼Java虛擬機棧又是什麼

每一個Java虛擬機線程都有一個私有的Java虛擬機棧(Java Virtual Machine Stack),與該線程同時建立,其內部保存一個個的棧幀(Stack Frame),對應着每一次的Java方法調用。其生命週期和線程保持一致。

做用

主管Java程序的運行,它保存方法的局部變量(8種基本數據類型、對象的引用地址)、部分結果,並參與方法的調用和返回。

局部變量,它是相比於成員變量來講的(或屬性)

基本數據類型變量 VS 引用類型變量(類、數組、接口)

棧的特色

棧是一種快速有效的分配存儲方式,訪問速度僅次於程序計數器。JVM直接對Java棧的操做只有兩個:

  • 每一個方法執行,伴隨着壓棧(入棧、進棧)
  • 執行結束後的出棧工做

對於棧來講不存在垃圾回收的問題(存在內存溢出的問題)。

與Java虛擬機棧相關異常

若是採用固定大小的Java虛擬機棧,那每個線程的Java虛擬機棧容量能夠在線程建立的時候獨立選定。若是線程請求分配的棧容量超過Java虛擬機棧容許的最大容量,Java虛擬機將會拋出StackOverflowError異常。

若是Java虛擬機棧能夠動態擴展,而且在嘗試擴展的時候沒法申請到足夠的內存,或者在建立新的線程時沒有足夠的內存去建立對應的虛擬機棧,那Java虛擬機將會拋出一個 OutOfMemoryError 異常。

public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count++);
        main(args);// Exception in thread "main" java.lang.StackOverflowError錯誤
    }
}

設置棧內存大小

咱們可使用參數-Xss選項來設置線程的最大棧空間,棧的大小直接決定了函數調用的最大可達深度。

-Xss1m
-Xss1k

3.2 棧的存儲單位

(1)棧幀

每一個線程都有本身的棧,棧中的數據都是以棧幀(Stack Frame)的格式存在。線程上正在執行的每一個方法都各自對應一個棧幀(Stack Frame)。棧幀是一個內存區塊,是一個數據集,維繫着方法執行過程當中的各類數據信息。

OOP的基本概念:類和對象

類中基本結構:field(屬性、字段、域)、method

JVM直接對Java棧的操做只有兩個,就是對棧幀的壓棧和出棧,遵循「先進後出/後進先出」原則。

在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱爲當前棧幀(Current Frame),與當前棧幀相對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)。

執行引擎運行的全部字節碼指令只針對當前棧幀進行操做。若是在該方法中調用了其餘方法,對應的新的棧幀會被建立出來,放在棧的頂端,成爲新的當前幀。

例子:

public class StackFrameTest {
    public static void main(String[] args) {
        method01();
    }

    private static int method01() {
        System.out.println("方法1的開始");
        int i = method02();
        System.out.println("方法1的結束");
        return i;
    }

    private static int method02() {
        System.out.println("方法2的開始");
        int i = method03();
        System.out.println("方法2的結束");
        return i;
    }

    private static int method03() {
        System.out.println("方法3的開始");
        int i = 30;
        System.out.println("方法3的結束");
        return i;
    }
}

輸出結果爲

方法1的開始
方法2的開始
方法3的開始
方法3的結束
方法2的結束
方法1的結束

知足棧先進後出的概念,經過DEBUG,也可以看到棧相關信息:

(2)棧運行原理

不一樣線程中所包含的棧幀是不容許存在相互引用的,即不可能在一個棧幀之中引用另一個線程的棧幀。

若是當前方法調用了其餘方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接着,虛擬機會丟棄當前棧幀,使得前一個棧幀從新成爲當前棧幀。

Java方法有兩種返回函數的方式,一種是正常的函數返回,使用return指令;另一種是拋出異常。無論使用哪一種方式,都會致使棧幀被彈出

(3)棧幀的內部結構

每一個棧幀中存儲着:

  • 局部變量表(Local Variables)
  • 操做數棧(Operand Stack)(或表達式棧)
  • 動態連接(Dynamic Linking)(或指向運行時常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
  • 一些附加信息

並行每一個線程下的棧都是私有的,所以每一個線程都有本身各自的棧,而且每一個棧裏面都有不少棧幀,棧幀的大小主要由局部變量表和操做數棧決定的。

3.3 局部變量表

(1)概述

局部變量表(Local Variables),又稱局部變量數組或本地變量表,是一個數字數組,主要用於存儲方法參數和定義在方法體內的局部變量,這些數據類型包括基本數據類型(8種)、對象引用(reference),以及returnAddress(指向了一條字節碼指令的地址)類型。

因爲局部變量表是創建在線程的棧上,是線程的私有數據,所以不存在數據安全問題

局部變量表所需的容量大小是在編譯期肯定下來的,並保存在方法的Code屬性的Maximum local variables數據項中。在方法運行期間是不會改變局部變量表的大小的。

例如上面案例的方法1:

方法嵌套調用的次數由棧的大小決定。通常來講,棧越大,方法嵌套調用次數越多。對一個函數而言,它的參數和局部變量越多,使得局部變量表膨脹,它的棧幀就越大,以知足方法調用所需傳遞的信息增大的需求。進而函數調用就會佔用更多的棧空間,致使其嵌套調用次數就會減小。

局部變量表中的變量只在當前方法調用中有效。在方法執行時,虛擬機經過使用局部變量表完成參數值到參數變量列表的傳遞過程。當方法調用結束後,隨着方法棧幀的銷燬,局部變量表也會隨之銷燬

(2)關於Slot的理解

參數值的存放老是在局部變量數組的index0開始,到數組長度-1的索引結束。

局部變量表,最基本的存儲單元是Slot(變量槽)。局部變量表中存放編譯期可知的基本數據類型、引用類型、returnAddress類型的變量。

在局部變量表裏,32位之內的類型只佔用一個slot(包括returnAddress類型),64位的類型(long和double)佔用兩個slot

byte、short、char 、boolean在存儲前被轉換爲int。0表示false,非0表示true。

JVM會爲局部變量表中的每個Slot都分配一個訪問索引,經過這個索引便可成功訪問到局部變量表中指定的局部變量值。

當一個實例方法被調用的時候,它的方法參數和方法體內部定義的局部變量將會按照順序被複制到局部變量表中的每個slot上。若是須要訪問局部變量表中一個64位的局部變量值時,只須要使用前一個索引便可。(好比:訪問long或doub1e類型變量)

若是當前幀是由構造方法或者實例方法建立的,那麼該對象引用this將會存放在index爲0的slot處,其他的參數按照參數表順序繼續排列。

Slot的重複利用

棧幀中的局部變量表中的槽位是能夠重用的,若是一個局部變量過了其做用域,那麼在其做用域以後申明的新的局部變就頗有可能會複用過時局部變量的槽位,從而達到節省資源的目的。

(3)靜態變量與局部變量的對比

變量的分類:

  • 按數據類型分:基本數據類型、引用數據類型
  • 按類中聲明的位置分:成員變量(類變量,實例變量)、局部變量

    • 類變量:Linking的Prepare階段,給類變量默認賦值,initial階段給類變量顯示賦值即靜態代碼塊。
    • 實例變量:隨着對象建立,會在堆空間中分配實例變量空間,並進行默認賦值。
    • 局部變量:在使用前必須進行顯式賦值,否則編譯不經過。

參數表分配完畢以後,再根據方法體內定義的變量的順序和做用域分配。

咱們知道類變量表有兩次初始化的機會,第一次是在「準備階段」,執行系統初始化,對類變量設置零值,另外一次則是在「初始化」階段,賦予程序員在代碼中定義的初始值。

和類變量初始化不一樣的是,局部變量表不存在系統初始化的過程,這意味着一旦定義了局部變量則必須人爲的初始化,不然沒法使用。

public void test(){
    int i;
    System.out.println(i);//報錯,局部變量沒有賦值不能使用。
}

在棧幀中,與性能調優關係最爲密切的部分就是前面提到的局部變量表。在方法執行時,虛擬機使用局部變量表完成方法的傳遞。

局部變量表中的變量也是重要的垃圾回收根節點,只要被局部變量表中直接或間接引用的對象都不會被回收

3.4 操做數棧

(1)概述

每個獨立的棧幀除了包含局部變量表之外,還包含一個後進先出(Last - In - First -Out)的 操做數棧,也能夠稱之爲表達式棧(Expression Stack)。

操做數棧,在方法執行過程當中,根據字節碼指令,往棧中寫入數據或提取數據,即入棧(push)/出棧(pop)

  • 某些字節碼指令將值壓入操做數棧,其他的字節碼指令將操做數取出棧。使用它們後再把結果壓入棧
  • 好比:執行復制、交換、求和等操做

操做數棧,主要用於保存計算過程的中間結果,同時做爲計算過程當中變量臨時的存儲空間

操做數棧就是JVM執行引擎的一個工做區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被建立出來,這個方法的操做數棧是的。

這個時候數組是有長度的,數組一旦建立,長度是不可變的。

每個操做數棧都會擁有一個明確的棧深度用於存儲數值,其所需的最大深度在編譯期就定義好了,保存在方法的Code屬性中,爲maxstack的值。

棧中的任何一個元素都是能夠任意的Java數據類型

  • 32bit的類型佔用一個棧單位深度
  • 64bit的類型佔用兩個棧單位深度

操做數棧並不是採用訪問索引的方式來進行數據訪問,而是隻能經過標準的入棧和出棧操做來完成一次數據訪問。

若是被調用的方法帶有返回值的話,其返回值將會被壓入當前棧幀的操做數棧中,並更新PC寄存器中下一條須要執行的字節碼指令。

操做數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,這由編譯器在編譯器期間進行驗證,同時在類加載過程當中的類檢驗階段的數據流分析階段要再次驗證。|

另外,咱們說Java虛擬機的解釋引擎是基於棧的執行引擎,其中的棧指的就是操做數棧。

(2)代碼追蹤

如下面代碼爲例子:

public void testAddOperation() {
    byte i = 15;
    int j = 8;
    int k = i + j;
}

使用javap 命令反編譯class文件: javap -v 類名.class

0 bipush 15
 2 istore_1
 3 bipush 8
 5 istore_2
 6 iload_1
 7 iload_2
 8 iadd
 9 istore_3
10 return

從上面的代碼咱們能夠知道,咱們都是經過bipush對操做數 15 和 8進行入棧操做,同時使用的是 iadd方法進行相加操做,i -> 表明的是int類型的加法操做。

Tips:byte、short、char、boolean 內部都是使用int型來進行保存的。

執行流程以下所示:

首先執行第一條語句,PC寄存器指向的是0,也就是指令地址爲0,而後使用bipush讓操做數15入棧。

執行完後,讓PC + 1,指向下一行代碼,下一行代碼就是將操做數棧的元素存儲到局部變量表1的位置,咱們能夠看到局部變量表的已經增長了一個元素。

爲何局部變量表不是從0開始的呢?

局部變量表也是從0開始的,可是由於0號位置存儲的是this指針,這裏省略書寫了。

而後PC+1,指向的是下一行。讓操做數8也入棧,同時執行store操做,存入局部變量表中。

而後從局部變量表中,依次將數據放在操做數棧中。

而後將操做數棧中的兩個元素執行相加操做,並存儲在局部變量表3的位置。

最後PC寄存器的位置指向10,也就是return方法,則直接退出方法。

(3)棧頂緩存技術

棧頂緩存技術:Top Of Stack Cashing

基於棧式架構的虛擬機所使用的零地址指令更加緊湊,但完成一項操做的時候必然須要使用更多的入棧和出棧指令,這同時也就意味着將須要更多的指令分派(instruction dispatch)次數和內存讀/寫次數。

因爲操做數是存儲在內存中的,所以頻繁地執行內存讀/寫操做必然會影響執行速度。爲了解決這個問題,HotSpot JVM的設計者們提出了棧頂緩存(Tos,Top-of-Stack Cashing)技術,將棧頂元素所有緩存在物理CPU的寄存器中,以此下降對內存的讀/寫次數,提高執行引擎的執行效率

3.5 動態連接

每個棧幀內部都包含一個指向運行時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是爲了支持當前方法的代碼可以實現動態連接(Dynamic Linking)。好比invokedynamic指令。

在Java源文件被編譯到字節碼文件中時,全部的變量和方法引用都做爲符號引用(Symbolic Reference)保存在class文件的常量池裏。好比:描述一個方法調用了另外的其餘方法時,就是經過常量池中指向方法的符號引用來表示的,那麼動態連接的做用就是爲了將這些符號引用轉換爲調用方法的直接引用

爲何須要運行時常量池?由於在不一樣的方法,均可能調用常量或者方法,因此只須要存儲一份便可,節省了空間。

常量池的做用:就是爲了提供一些符號和常量,便於指令的識別。

3.6 方法返回地址

當一個方法開始執行後,只有兩種方式能夠退出這個方法:

  • 正常執行完成
  • 出現未處理的異常

執行引擎遇到任意一個方法返回的字節碼指令(return),會有返回值傳遞給上層的方法調用者,簡稱「正常調用完成」(Normal Method Invocation Completion):

  • 一個方法在正常調用完成以後,究竟須要使用哪個返回指令,還須要根據方法返回值的實際數據類型而定。
  • 在字節碼指令中,返回指令包含ireturn(當返回值是boolean,byte,char,short和int類型時使用),lreturn(Long類型),freturn(Float類型),dreturn(Double類型),areturn。另外還有一個return指令聲明爲void的方法,實例初始化方法,類和接口的初始化方法使用。

在方法執行過程當中遇到異常(Exception),而且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜索到匹配的異常處理器,就會致使方法退出,這種退出方法的方式稱爲「異常調用完成「(Abrupt MethodInvocation Completion)。

不管經過哪一種方式退出,在方法退出後都返回到該方法被調用的位置。方法正常退出時,調用者的PC寄存器的值做爲返回地址,即調用該方法的指令的下一條指令的地址。而經過異常退出的,返回地址是要經過異常表來肯定,棧幀中通常不會保存這部分信息。

本質上,方法的退出就是當前棧幀出棧的過程。此時,須要恢復上層方法的局部變量表、操做數棧、將返回值壓入調用者棧幀的操做數棧、設置PC寄存器值等,讓調用者方法繼續執行下去。

正常完成出口和異常完成出口的區別在於:經過異常完成出口退出的不會給他的上層調用者產生任何的返回值

3.7 附加信息

《Java虛擬機規範》容許虛擬機實現增長一些規範裏沒有描述的信息到棧幀之中,例如與調試、性能收集相關的信息,這部分信息徹底取決於具體的虛擬機實現,這裏再也不詳述。

參考

深刻理解Java虛擬機:JVM高級特性與最佳實踐(第3版)

運行時數據區Oracle官網介紹

相關文章
相關標籤/搜索