JVM詳解(三)——執行時資料區

L發表於2021-10-17

一、概述

1、介紹

  類比一下:紅框就好比記憶體的執行時資料區,在各自不同的位置放了不同的東西。而廚師就好比執行引擎。

  記憶體是非常重要的系統資源,是硬碟和CPU的中間倉庫及橋樑,承載著作業系統和應用程式的實時執行。JVM記憶體佈局規定了Java在執行過程中記憶體申請、分配、管理的策略,保證了JVM的高效穩定執行。不同的JVM對於記憶體的劃分方式和管理機制存在著部分差異(典型的不同,就是針對方法區)。結合JVM虛擬機器規範,來探討一下經典的JVM記憶體佈局。執行時資料區-詳圖:

  紅框處有變化,叫方法區,JDK7之前叫永久代,JDK8之後叫元空間。整個紅框也可以叫非堆空間。具體變化在方法區一節中會詳細闡述。
  Java虛擬機器定義了若干個程式執行期間會使用到的執行時資料區,其中有一些會隨著虛擬機器啟動而建立,退出而銷燬;另外一些是與執行緒一一對應的,會隨著執行緒開始而建立,結束而銷燬。
  灰色,每個執行緒私有:程式計數器(PC)、虛擬機器棧(VMS)、本地方法棧(NMS)。
  紅色,執行緒共享:堆、堆外空間(方法區:永久代或叫元空間 + 程式碼快取)。

  JVM優化當中,執行緒裡面的結構沒有太多優化的點,重點說的優化(我們講垃圾回收)指的是堆空間,當然也包括方法區(主要放類的資訊)。從頻率來上說,95%的垃圾回收都集中在堆區,5%是集中在方法區。方法區,jdk8以後,又叫元空間,使用的是本地記憶體。本地記憶體還是比較大的,如果沒有進行過引數設定,方法區一般來說,不會出現溢位,因為本地記憶體一般比較大。
  每個JVM只有一個Runtime例項,即為執行時環境,相當於記憶體結構中間的那個框框:執行時環境。
  Runtime:執行時物件,一個JVM例項,就對應一個Runtime的例項。Runtime例項物件就相當於執行時資料區。整個執行時資料區對虛擬機器來說只有一份。Runtime是單例的。

2、執行緒

  執行緒是程式裡的一個執行單元,JVM允許一個應用有多個執行緒並行的執行。在HotSpot JVM裡,每個執行緒都與作業系統的本地執行緒直接對映。當一個Java執行緒準備好執行以後,此時一個作業系統的本地執行緒也同時建立。Java執行緒執行終止後,本地執行緒也會回收。作業系統負責所有執行緒的安排排程到任何一個可用的CPU上,一旦本地執行緒初始化成功,它就會呼叫Java執行緒中的run()方法。
  如果程式中,都是守護執行緒,那麼虛擬機器就可以退出了。
  主要的後臺系統執行緒在HotSpot JVM裡主要是一下幾個:
  虛擬機器執行緒:這種執行緒的操作是需要JVM達到安全點才會出現。這些操作必須在不同的執行緒中發生的原因是他們都需要JVM達到安全點,這樣堆才不會變化。這種執行緒的執行型別包括"stop-the-world"的垃圾收集,執行緒棧收集,執行緒掛起以及偏向鎖撤銷。
  週期任務執行緒:這種執行緒是時間週期事件的體現(比如中斷),他們一般用於週期性操作的排程執行。
  GC執行緒:這種執行緒對在JVM裡不同種類的垃圾收集行為提供了支援。
  編譯執行緒:這種執行緒在執行時會將位元組碼編譯成到原生程式碼。
  訊號排程執行緒:這種執行緒接收資訊併傳送給JVM,在它內部通過呼叫適當的方法進行處理。

二、程式計數器

1、介紹

  JVM中的程式計數暫存器(Program Counter Register),Register的命名源於CPU的暫存器,暫存器儲存指令相關的現場資訊。CPU只有把資料裝載到暫存器才能能夠執行。這裡,並非是廣義上所指的物理暫存器,或許將其翻譯為PC計數器(或指令計數器,也稱程式鉤子)會更貼切,並且不容易引起不必要的誤會。JVM中的PC暫存器是對物理PC暫存器的一種抽象模擬。
  作用:PC暫存器用來儲存指向下一條指令的地址,即,將要執行的指令程式碼,由執行引擎讀取下一條指令。

  它是一塊很小的記憶體空間,幾乎可以忽略不計,也是執行速度最快的儲存區域。在JVM規範中,每個執行緒都有自己的程式計數器,是執行緒私有的,生命週期與執行緒的生命週期保持一致。在任何時間,一個執行緒都只有一個方法在執行,也就是當前方法。程式計數器會儲存當前執行緒正在執行的Java方法的JVM指令地址。或者,如果是在執行native方法,則是未指定值(undefined)。
  它是程式控制流的指示器,分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。它是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemoryError情況的區域。PC既沒有GC ,也沒有OOM。
  理解成遊標、或者Java裡集合的迭代器。用於指明當前程式到哪兒了。
  程式碼示例:使用說明

JVM詳解(三)——執行時資料區
 1 public class Main {
 2     public static void main(String[] args) {
 3         Main test = new Main();
 4         test.minus();
 5     }
 6 
 7     public int minus() {
 8         int c = 301;
 9         int d = 401;
10         return c - d;
11         
12         // int k = c + d;
13         // String s = "abc";
14     }
15 }
使用說明

  位元組碼檔案:

2、常見問題

  使用PC暫存器儲存位元組碼指令地址有什麼用呢?(為什麼使用PC暫存器記錄當前執行緒的執行地址呢?)
  因為CPU需要不停的在A、B、C各個執行緒之間切換,切換回來以後,需要知道接著從哪開始繼續執行。JVM的位元組碼直譯器就需要通過改變PC暫存器的值來明確下一條應該執行什麼樣的位元組碼指令。

  PC暫存器為什麼會是執行緒私有?
  所謂的多執行緒,在一個特定的時間段只會執行其中某一個執行緒的方法,CPU不停的做切換,這樣必然導致經常中斷或恢復,為了能夠準確的記錄各個執行緒正在執行的當前位元組碼指令地址,最好的辦法就是每個執行緒都分配一個PC暫存器,這樣各個執行緒之間便可以進行獨立計算,從而不會出現相互干擾的情況。
  由於CPU時間片限制,眾多執行緒在併發執行過程中,任何一個確定的時刻,一個處理器或多核處理器中的一個核心,只會執行某個執行緒中的一條指令。每個執行緒在建立後,都會產生自己的程式計數器和棧幀,程式計數器在各個執行緒之間互不影響。
  這個很容器理解,不各自一份的話,在切換的時候,執行緒1執行到哪一條指令不就丟失了嘛。

3、CPU時間片

  CPU時間片,即CPU分配給各個程式的時間,每個執行緒被分配一個時間片段,稱作時間片。
  在巨集觀上:我們可以同時開啟多個應用程式,每個程式並行不悖,同時執行。
  在微觀上:由於只有一個CPU,一次只能處理程式要求的一部分,通過CPU時間片輪詢的方式。

三、虛擬機器棧

1、介紹

  棧和暫存器:由於跨平臺性的設計,Java的指令都是根據棧來設計的。不同平臺CPU架構不同,所以不能設計為基於暫存器的。優點是跨平臺,指令集小,編譯器容易實現,缺點是效能下降,實現同樣的功能需要更多的指令。
  記憶體中的棧與堆:棧是執行時的單位,而堆是儲存的單位。棧管執行,堆管儲存。棧解決程式的執行問題,即程式如何執行,或者說如何處理資料。堆解決資料儲存的問題,即資料怎麼放,放在哪兒。
  Java虛擬機器棧,早期也叫Java棧,每個執行緒在建立時都會建立一個虛擬機器棧,其內部儲存一個個的棧幀,對應著一次次的Java方法呼叫,是執行緒私有的。生命週期與執行緒一致,一個執行緒,對應一個Java虛擬機器棧。
  作用:主管Java程式的執行,它儲存了方法的區域性變數(8種基本資料型別、物件的引用地址)、部分結果、並參與方法的呼叫和返回。

  主體的資料都在堆中放,物件主要在堆中放。方法內的區域性變數,是放在棧空間中的。指的基本資料型別,要是引用資料型別,在棧空間,只是放了物件的引用。

  每一個方法,跟一個棧幀都是一一對應的關係。很顯然:目前方法B,是在這個棧的棧頂,把棧頂的這個方法,叫做當前方法。當方法B執行完以後,綠框就出棧了,它一出棧,方法A就變成了當前方法。一次次的方法呼叫,就對應著一個個棧幀的入棧出棧操作。虛擬機器棧是隨著執行緒的建立而建立的,自然也隨著執行緒的消亡而消亡。當主執行緒(main方法)的methodA執行完以後,主執行緒就結束了,當前這個虛擬機器棧就結束了。
  優點:棧是一種快速有效的分配儲存方式,訪問速度僅次於程式計數器。JVM直接對Java棧的操作只有兩個:每個方法執行,伴隨著入棧,執行結束後,伴隨著出棧。對於棧來說,不存在垃圾回收問題。

  棧的常見異常?
  Java虛擬機器規範允許Java棧的大小是動態的或者固定不變的。
  如果是固定大小的,每一個執行緒的Java虛擬機器棧容量可以線上程建立的時候獨立選定,若執行緒請求分配的棧容量超過Java虛擬機器棧允許的最大容量,Java虛擬機器將會丟擲一個StackOverflowError異常。
  如果是動態擴充套件的,則在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的虛擬機器棧,那Java虛擬機器將會丟擲一個OutOfMemoryError異常。
  程式碼示例:

1 // 方法的遞迴呼叫.棧溢位
2 public class Main {
3     public static void main(String[] args) {
4         main(args);
5     }
6 }
7 
8 // Exception in thread "main" java.lang.StackOverflowError

  如何設定棧的大小?
  使用引數-Xss來設定執行緒的最大棧空間,棧的大小直接決定了函式呼叫的最大可達深度。
  程式碼示例:

 1 public class Main {
 2     private static int count = 1;
 3 
 4     public static void main(String[] args) {
 5         System.out.println(count);
 6         count++;
 7         main(args);
 8     }
 9 }
10 
11 // 未設定棧大小.預設1024k
12 // 11420
13 // Exception in thread "main" java.lang.StackOverflowError
14 
15 // 設定棧大小 -Xss256k
16 // 2463
17 // Exception in thread "main" java.lang.StackOverflowError

2、棧的儲存單位(棧幀)

  每個執行緒都有自己的棧,棧中的資料都是以棧幀(Stack Frame)的格式存在。在這個執行緒上的每個方法都各自對應一個棧幀。棧幀是一個記憶體區塊,是一個資料集,維繫著方法執行過程中的各種資料資訊。方法和棧幀是一一對應的關係。一個方法的執行就對應一個棧幀的入棧。方法的結束,這個棧幀就會出棧。
  棧的執行原理:JVM直接對Java棧的操作只有兩個,就是對棧幀的入棧和出棧。在一條活動執行緒中,一個時間點上,只會有一個活動的棧幀,即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為當前棧幀,與當前棧幀相對應的方法就是當前方法,定義這個方法的類就是當前類。
  執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。如果在該方法中呼叫了其他方法,對應的新的棧幀會被建立出來,放在棧的頂端,成為新的當前幀。執行原理:

  不同執行緒中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀之後引用另外一個執行緒的棧幀。如果當前方法呼叫了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接著,虛擬機器會丟棄當前棧幀,使得前一個棧幀重新成為當前棧幀。Java方法有兩種返回方式,一種是正常函式返回,使用return指令;另外一種是丟擲異常(未捕獲)。不管使用哪種方式,都會導致棧幀被彈出。
  棧的內部結構:區域性變數表,運算元棧,動態連結(指向執行時常量池的方法引用),方法返回地址(或方法正常退出或者異常退出的定義),一些附加資訊。

 

  棧的大小:一個固定大小的棧,滿了就會報stackoverflow,那麼一個棧,到底能放多少棧幀呢?
  如果棧的大小是固定的,那麼取決於棧幀的大小。棧幀小就能放的多,棧幀大就放的少。棧幀的大小主要取決於區域性變數表,運算元棧。而一個棧幀的大小,又影響棧中能存放棧幀的個數。以及大概什麼時候會出現異常。

3、區域性變數表

  區域性變數表也稱為區域性變數陣列或本地變數表,是一個一維的陣列。定義為一個數字陣列,主要用於儲存方法形參和方法的區域性變數,包括基本資料型別、物件引用,以及returnAddress型別。這些在編譯期可知。
  由於區域性變數表是建立線上程的棧上,是執行緒私有的,因此不存在資料安全問題。
  區域性變數表所需的容量大小是在編譯期就確定了,並儲存在方法的code屬性的maximum local variable資料項中,在方法執行期間是不會改變的。
  棧幀中,區域性變數表的長度,所有的方法,在編譯完以後,就確定了。
  方法巢狀呼叫的次數由棧的大小決定。區域性變數表中的變數只在當前方法呼叫中有效。在方法執行時,虛擬機器通過使用區域性變數表完成引數值到引數變數列表的傳遞過程。當方法呼叫結束後,隨著方法棧幀的銷燬,區域性變數表也隨之銷燬。皮之不存毛將焉附。
  程式碼示例:區域性變數表大小

 1 // 區域性變數表的大小在編譯期就確定
 2 public class Main {
 3     public static void main(String[] args) {
 4         Main test = new Main();
 5         int num = 10;
 6         test.methodA();
 7     }
 8 
 9     public void methodA() {
10 
11     }
12 }

  解析位元組碼檔案(擷取部分):

  Start PC:0、8、11 ,變數作用域的起始位置。
  length:16,指的長度(偏移量),不是終止位置。
  StartPC + length = 16(code length)

  slot:槽

  引數值的存放在區域性變數陣列的 index0~length-1。區域性變數表,最基本的儲存單元是slot(變數槽)。在區域性變數表裡,32位以內的型別只佔用一個slot,64位的型別(long、double)佔用兩個slot。byte、short、char、boolean在儲存前被轉換為int,0表示false,非0表示true。
  JVM為區域性變數表每一個slot分配一個訪問索引,通過這個索引訪問。當一個方法被呼叫的時候,方法形參和方法的區域性變數將按順序被複制到每一個slot上。若需要訪問一個64位的區域性變數,只需要使用前一個索引即可。
  若當前棧幀是由構造方法或例項方法建立的,那麼index0將會存放物件引用this,其餘的引數按順序排列。slot-槽:

  slot的重複利用
  棧幀中的區域性變數表中的槽是可以重用的。如果一個區域性變數過了其作用域,那麼在其後宣告的新的變數就會複用過期的槽位,從而達到節省資源的目的。
  程式碼示例:

 1 package com.lx.jvm.day01;
 2 
 3 public class Main {
 4     public static void main(String[] args) {
 5 
 6     }
 7 
 8     public void test() {
 9         int a = 100;
10         {
11             int b = 200;
12             b = a + 300;
13         }
14 
15         int c = a + 400;
16     }
17 }

  區域性變數表:

  可以看到:變數c使用的是之前變數b銷燬的slot的位置,索引為2。且變數b的作用域也不同。slot索引0 放的this。
  變數的分類

  總結
  在棧幀中,與效能調優關係最為密切的部分就是前面提到的區域性變數表。在方法執行時,虛擬機器使用區域性變數表完成方法的傳遞。
  區域性變數表中的變數也是重要的垃圾回收根節點,只要被區域性變數表中直接或間接引用的對用都不會被回收。

  區域性變數表中的變數,如果不存在了(這個指標不存在了)。 那這邊(堆空間)的垃圾就需要被回收。這就涉及到一個效能調優的問題。
  比如:我們說棧溢位了,記憶體不夠了。棧當中,佔據空間比較大的,是區域性變數表。區域性變數表越大,棧幀就越大。整個棧裡,能夠巢狀的個數就越少。

4、運算元棧

  運算元棧,在方法執行過程中,根據位元組碼指令,往棧中寫入,提取資料,即入棧,出棧。某些位元組碼指令將值壓入運算元棧,其餘的位元組碼指令將運算元取出,使用後再把結果壓棧。比如:執行復制,交換,求和等操作。
  棧:可以使用陣列或連結串列來實現。運算元棧和區域性變數表,都是用陣列實現的。

  運算元棧,主要用於儲存計算過程的中間結果,同時作為計算過程中變數的臨時儲存空間。運算元棧就是JVM執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也隨之被建立,這個方法的運算元棧是空的。每一個運算元棧都擁有一個明確的棧深度用於儲存數值,其所需的最大深度在編譯期就確定了,儲存在方法的code屬性中的max_stack的值。
  棧中的元素可以是Java任意資料型別,32位佔用一個棧單位,64位佔用兩個。運算元棧並非採用訪問索引的方式來進行資料訪問的,而是隻能通過標準的出棧,入棧操作來完成一次資料訪問。
  如果被呼叫的方法帶有返回值,其返回值將會被壓入當前棧幀的運算元棧中,並更新PC暫存器中下一條執行的位元組碼指令。運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,這由編譯器在編譯期間進行驗證,同時在類載入過程中的類檢驗階段的資料流分析階段要再次驗證。因為在編譯期,需要確定操作棧的大小,而不同型別需要的棧大小是不同的。
  另外,我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧。
  運算元棧是用陣列實現。而我們知道,陣列一旦被建立,其長度就是確定的!那麼,上面提到的"這個方法的運算元棧是空的",此時,陣列被建立了,這個陣列的長度是多少呢?就是下面提到的 max_stack。
  程式碼示例:程式碼追蹤示例:

 1 public void test() {
 2     // byte、short、char、boolean,都是以int型來儲存
 3     byte i = 15;
 4     int j = 8;
 5     int k = i + j;
 6 
 7     // int m = 800;
 8 }
 9 
10 // 對應的 bytecode
11  0 bipush 15
12  2 istore_1
13  3 bipush 8
14  5 istore_2
15  6 iload_1
16  7 iload_2
17  8 iadd
18  9 istore_3
19 10 return

  動態過程:

  iadd操作(位元組碼指令)是從運算元棧中,彈出 8 和 15 ,由執行引擎解析為機器指令,交給CPU執行後,得出結果為23,並把執行結果壓棧。

 1 // 再看一下這個的位元組碼指令
 2 public void test() {
 3     byte i = 15;
 4     int j = 8;
 5     int k = i + j;
 6     
 7     int m = this.test1();
 8 }
 9 
10 public int test1() {
11     return 20 + 30;
12 }

  aload_0:獲取上一個棧幀返回的結果,並儲存在運算元棧中。

  棧頂快取技術:

  前面介紹過,基於棧式架構的虛擬機器所使用的零地址指令更加緊湊,但完成一項操作需要使用更多的入棧,出棧指令,這同時也就意味著將需要更多的指令分派次數和記憶體讀寫次數。
  由於運算元是儲存在記憶體中的,因此頻繁的執行記憶體讀寫操作必然會影響執行速度。為了解決這個問題,HotSpot JVM的設計者們提出了棧頂快取技術,將棧頂元素全部快取在物理CPU的暫存器中,以此降低對記憶體的讀寫次數,從而提升執行引擎的執行效率。暫存器,指令更少,執行速度快。

5、動態連結

  動態連結(或指向執行時常量池的方法引用):每一個棧幀內部包含一個指向執行時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是為了支援當前方法的程式碼能夠實現動態連結(Dynamic Linking),比如:invokedynamic指令。
  在位元組碼檔案中,所有的變數和方法引用都作為符號引用儲存在class檔案的常量池裡。動態連結的作用就是為將這些符號引用轉換為呼叫方法的直接引用。

  執行時常量池,就是上面的constant pool。在java.exe之後,就把constant pool,存在方法區,執行時常量池這個位置。
  為什麼需要常量池?
  作用:為了提供一些符號和常量,便於指令的識別。一個在位元組碼檔案裡,一個在執行時方法區中,可以使得位元組碼檔案比較小,不能什麼都在位元組碼檔案都寫明,比如父類Object等等。

6、方法返回地址

  存放呼叫該方法的PC暫存器的值。
  方法退出後,都返回到該方法被呼叫的位置。方法正常退出時,呼叫者的PC暫存器的值作為返回地址,即呼叫該方法的指令的下一條指令的地址。異常退出時,返回地址要通過異常表來確定,棧幀中一般不會儲存這部分資訊。
  當一個方法開始執行後,只有兩種方式可以退出這個方法:
  正常完成出口:執行引擎遇到任意一個方法返回的位元組碼指令(return),會有返回值傳遞給上層的方法呼叫者,簡稱正常完成出口。
  異常完成出口:在方法執行的過程中遇到了異常(Exception),並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,簡稱異常完成出口。
  方法執行過程中丟擲異常時的異常處理,儲存在一個異常處理表,方便在發生異常的時候找到處理異常的程式碼。
  本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的區域性變數表、運算元棧、將返回值壓入呼叫者棧幀的運算元棧、設定PC暫存器值等,讓呼叫者方法繼續執行下去。
  正常返回和異常返回的區別在於:通過異常完成出口退出的不會給他的上層呼叫者產生任何的返回值。

7、一些附加資訊

  棧幀中還允許攜帶與Java虛擬機器實現相關的一些附加資訊,比如:對程式除錯提供支援的資訊。

四、方法的呼叫

1、方法的繫結機制

  在JVM中,將符號引用轉換為呼叫方法的直接引用與方法的繫結機制相關。
  靜態連結:當一個位元組碼檔案被裝載進JVM內部時,如果被呼叫的目標方法在編譯期可知,且執行期保持不變,這種情況,將呼叫方法的符號引用轉換為直接引用的過程稱為靜態連結。
  動態連結:如果被呼叫的目標方法在編譯期不可確定,即,只能夠在程式執行期將呼叫方法的符號引用轉換為直接引用,由於這種引用轉換過程具備動態性,稱為動態連結。
  對應的方法的繫結機制為:早期繫結和晚期繫結。繫結是一個欄位、方法或類在符號引用被替換為直接引用的過程,這僅僅發生一次。
  早期繫結:指被呼叫的目標方法如果在編譯期可知,且執行期保持不變時,可將這個方法與所屬的型別進行繫結,這樣一來,由於明確了被呼叫的目標方法究竟是哪一個,因此也就可以使用靜態連結的方式將符號引用轉換為直接引用。
  晚期繫結:指如果被呼叫的方法在編譯期無法確定下來,只能夠在程式執行期根據實際的型別繫結相關的方法,這種繫結方式稱為晚期繫結。
  程式碼示例:早期繫結和晚期繫結

JVM詳解(三)——執行時資料區
 1 interface Huntable {
 2     void hunt();
 3 }
 4 
 5 class Animal {
 6     public void eat() {
 7         System.out.println("動物進食");
 8     }
 9 }
10 
11 
12 class Dog extends Animal implements Huntable {
13 
14     @Override
15     public void hunt() {
16         System.out.println("狗捕耗子");
17     }
18 
19     @Override
20     public void eat() {
21         System.out.println("狗吃骨頭");
22     }
23 }
24 
25 
26 class Cat extends Animal implements Huntable {
27 
28     public Cat() {
29         // 表現為:早期繫結
30         super();
31     }
32 
33     public Cat(String name) {
34         // 表現為:早期繫結
35         this();
36     }
37 
38     @Override
39     public void hunt() {
40         System.out.println("貓捕耗子");
41     }
42 
43     @Override
44     public void eat() {
45         // 表現為:早期繫結
46         super.eat();
47         System.out.println("貓吃魚");
48     }
49 }
50 
51 
52 public class Test {
53 
54     public static void main(String[] args) {
55 
56     }
57 
58     public void showAnimal(Animal animal) {
59         // 表現為:晚期繫結
60         animal.eat();
61     }
62 
63     public void showHunt(Huntable huntable) {
64         // 表現為:晚期繫結
65         huntable.hunt();
66     }
67 }
早期繫結和晚期繫結

  隨著高階語言的橫空出世,類似於Java一樣的基於物件導向的程式語言如今越來越多,儘管這類程式語言在語法風格上存在一定的差別,但是它們彼此之間始終保持一個共性,那就是都支援封裝,繼承和多型等物件導向特性,既然這一類的程式語言具備多型特性,那麼自然也就具備早期繫結和晚期繫結兩種繫結方式。
  Java中任何一個普通的方法其實都具備虛擬函式的特徵,它們相當於C++語言中的虛擬函式(C++中則需要使用關鍵字virtual來顯示定義)。如果在Java程式中不希望某個方法擁有虛擬函式的特徵時,則可以使用關鍵字final來修飾。

2、虛方法與非虛方法

  非虛方法:如果方法在編譯期就確定了具體的呼叫版本,這個版本在執行時是不可變的,這樣的方法稱為非虛方法。表現為早期繫結或靜態連結。靜態方法、私有方法、final 方法、例項構造器、父類方法都是非虛方法。
  虛方法:其他方法稱為虛方法。
子類物件的多型性的使用前提:①類的繼承關係,②方法的重寫。

  虛擬機器中提供了以下幾條方法呼叫指令:
  普通呼叫指令:

  invokestatic:呼叫靜態方法,解析階段確定唯一方法版本。
  invokespecial:呼叫<init>方法、私有及父類方法,解析階段確定唯一方法版本。
  invokevirtual:呼叫所有虛方法。
  invokeinterface:呼叫介面方法。

  動態呼叫指令:

  invokedynamic:動態解析出需要呼叫的方法,然後執行。

  前四條指令固化在虛擬機器內部,方法的呼叫執行不可人為干預,而invokedynamic指令則支援由使用者確定方法版本。其中invokestatic指令和invokespecial指令呼叫的方法稱為非虛方法,其餘的(final修飾的除外)稱為虛方法。
  程式碼示例:4種指令

JVM詳解(三)——執行時資料區
 1 public class Main {
 2     public static void main(String[] args) {
 3         Son so = new Son();
 4         so.show();
 5     }
 6 }
 7 
 8 class Son extends Father {
 9     public Son() {
10         // invokespecial
11         super();
12     }
13 
14     public Son(int age) {
15         // invokespecial
16         this();
17     }
18 
19     // 不是重寫的父類的靜態方法,因為靜態方法不能被重寫!
20     public static void showStatic(String str) {
21         System.out.println("son " + str);
22     }
23 
24     private void showPrivate(String str) {
25         System.out.println("son private" + str);
26     }
27 
28     public void show() {
29         // invokestatic
30         showStatic("atguigu.com");
31         // invokestatic
32         super.showStatic("good!");
33         // invokespecial
34         showPrivate("hello!");
35         // invokespecial
36         super.showCommon();
37 
38         // 因為此方法宣告有final,不能被子類重寫,所以也認為此方法是非虛方法。
39         // invokevirtual
40         showFinal();
41 
42         // 虛方法如下:
43         // invokevirtual
44         showCommon();
45         info();
46 
47         MethodInterface in = null;
48         // invokeinterface
49         in.methodA();
50     }
51 
52     public void info() {
53 
54     }
55 
56     public void display(Father f) {
57         f.showCommon();
58     }
59 
60 }
61 
62 class Father {
63     public Father() {
64         System.out.println("father的構造器");
65     }
66 
67     public static void showStatic(String str) {
68         System.out.println("father " + str);
69     }
70 
71     public final void showFinal() {
72         System.out.println("father show final");
73     }
74 
75     public void showCommon() {
76         System.out.println("father 普通方法");
77     }
78 }
79 
80 interface MethodInterface {
81     void methodA();
82 }
4種指令

  invokedynamic指令:
  JVM位元組碼指令集一直比較穩定,一直到Java7中才增加了一個invokedynamic指令,這是Java為了實現【動態型別語言】支援而做的一種改進。
  但是在Java7中並沒有提供直接生成invokedynamic指令的方法,需要藉助ASM這種底層位元組碼工具來產生invokedynamic指令。直到Java8的Lambda表示式的出現,invokedynamic指令的生成,在Java中才有了直接的生成方式。
  Java7中增加的動態語言型別支援的本質是對Java虛擬機器規範的修改,而不是對Java語言規則的修改,這一塊相對來講比較複雜,增加了虛擬機器中的方法呼叫,最直接的受益者就是執行在Java平臺的動態語言的編譯器。
  動態型別語言和靜態型別語言:動態型別語言和靜態型別語言的區別就在於對型別的檢查是在編譯期還是執行期,滿足前者就是靜態型別語言,反之就是動態型別語言。再直白一點就是,靜態型別語言是判斷變數自身的型別資訊;動態型別語言是判斷變數值的型別資訊,變數沒有型別,變數值才有型別資訊,這是動態語言的一個重要特徵。
  Java語言是靜態型別語言。

  Java:String info = "haha"; // info = haha;
  JS:var name = "hehe"; var name = 10;

  程式碼示例:體會invokedynamic

JVM詳解(三)——執行時資料區
 1 public class Main {
 2     public static void main(String[] args) {
 3         Lambda lambda = new Lambda();
 4 
 5         // invokedynamic
 6         Func func = s -> {
 7             return true;
 8         };
 9 
10         lambda.lambda(func);
11 
12         // invokedynamic
13         lambda.lambda(s -> {
14             return true;
15         });
16     }
17 }
18 
19 class Lambda {
20 
21     public void lambda(Func func) {
22         return;
23     }
24 
25 }
26 
27 @FunctionalInterface
28 interface Func {
29     public boolean func(String str);
30 }
體會

3、方法重寫的本質

  Java語言中方法重寫的本質:
  (1)找到運算元棧頂的第一個元素所執行的物件的實際型別,記作C。
  (2)如果在型別 C 中找到與常量中的描述符合簡單名稱都相符的方法,則進行訪問許可權校驗。如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
  (3)否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。
  (4)如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。
  IllegalAccessError介紹:程式試圖訪問或修改一個屬性或呼叫一個方法,這個屬性或方法,你沒有許可權訪問。一般的,這個會引起編譯器異常。這個錯誤如果發生在執行時,就說明一個類發生了不相容的改變。

4、虛方法表

  在物件導向程式設計中,會很頻繁的使用到動態分派。如果在每次動態分派的過程中都要重新在類的方法後設資料中搜尋合適的目標的話就可能影響到執行效率。因此,為了提高效能,JVM採用在類的方法區建立一個虛方法表(virtual method table)(非虛方法不會出現在表中)來實現。使用索引表來代替查詢。
  每個類中都有一個虛方法表,表中存放著各個方法的實際入口。
  那麼虛方法表什麼時候被建立?
  虛方法表會在類載入的連結階段被建立並初始化,類的變數初始值準備完成之後,JVM會把該類的方法表也初始化完畢。

  程式碼示例:虛方法表

JVM詳解(三)——執行時資料區
 1 interface Friendly {
 2     void sayHello();
 3 
 4     void sayGoodbye();
 5 }
 6 
 7 class Cat implements Friendly {
 8     public void eat() {
 9     }
10 
11     @Override
12     public void sayHello() {
13     }
14 
15     @Override
16     public void sayGoodbye() {
17     }
18 
19     @Override
20     protected void finalize() {
21     }
22 
23     @Override
24     public String toString() {
25         return "Cat";
26     }
27 }
28 
29 class Dog {
30 
31     public void sayHello() {
32     }
33 
34     @Override
35     public String toString() {
36         return "Dog";
37     }
38 
39 }
40 
41 class CockerSpaniel extends Dog implements Friendly {
42 
43     @Override
44     public void sayHello() {
45         super.sayHello();
46     }
47 
48     @Override
49     public void sayGoodbye() {
50     }
51 }
虛方法表

  Dog虛方法表:

  CockerSpaniel虛方法表:

  Cat虛方法表:

五、本地方法棧

1、介紹

  Java虛擬機器棧用於管理Java方法的呼叫,而本地方法棧用於管理本地方法的呼叫。本地方法棧,執行緒私有的。
  允許被實現成固定或者可動態擴充套件的記憶體大小(在記憶體溢位方面是相同的):
  (1)如果執行緒請求分配的棧容量超過本地方法棧允許的最大容量,Java虛擬機器將會丟擲一個StackOverflowError異常。
  (2)如果本地方法棧可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠記憶體去建立對應的本地方法棧,那麼Java虛擬機器將會丟擲一個OutOfMemoryError異常。
  本地方法是用C語言實現的。它的具體做法是Native Method Stack中登記native方法,在執行引擎執行時載入本地方法庫。
  當某個執行緒呼叫一個本地方法時,它就進入了一個全新的並且不再受虛擬機器限制的世界。它和虛擬機器擁有同樣的許可權。
  (1)本地方法可以通過本地方法介面來訪問虛擬機器內部的執行時資料區。
  (2)它甚至可以直接使用本地處理器中的暫存器。
  (3)直接從本地記憶體的堆中分配任意數量的記憶體。
  並不是所有的JVM都支援本地方法。因為Java虛擬機器規範並沒有明確要求本地方法棧的使用語言、具體實現方式、資料結構等。如果JVM產品不打算支援native方法,也無需實現本地方法棧。
  在HotSpot JVM中,直接將本地方法棧和虛擬機器棧合二為一了。
  程式碼示例:舉例

JVM詳解(三)——執行時資料區
 1 System.currentTimeMillis();
 2 
 3 new Thread().start();
 4 
 5 // 原始碼
 6 public static native long currentTimeMillis();
 7 
 8 public synchronized void start() {
 9 
10     if (threadStatus != 0)
11         throw new IllegalThreadStateException();
12     group.add(this);
13 
14     boolean started = false;
15     try {
16         start0();
17         started = true;
18     } finally {
19         try {
20             if (!started) {
21                 group.threadStartFailed(this);
22             }
23         } catch (Throwable ignore) {
24             /* do nothing. If start0 threw a Throwable then
25               it will be passed up the call stack */
26         }
27     }
28 }
29 
30 private native void start0();
原始碼

六、本地方法介面

1、介紹

  這個模組並不屬於執行時資料區。簡單地講,一個Native Method就是一個Java呼叫非Java程式碼的介面。該方法的實現由非Java語言實現,比如C。這個特徵並非Java所特有,很多其他的程式語言都有這一機制,比如在C++中,你可以用extern "C"告知C++編譯器去呼叫一個C的函式。
  "A native method is a Java method whose implementation is provided by non-java code"
  在定義一個native method時,並不提供實現體(有些像定義一個Java interface),其實現體由非Java語言在外面實現。
  本地介面的作用是融合不同的程式語言為Java所用,它的初衷是融合C/C++程式。
  程式碼示例:

 1 public native void method1(int x);
 2 
 3 public native static long method2();
 4 
 5 private native synchronized float method3(Object o);
 6 
 7 native void method4(int[] x) throws Exception;
 8 
 9 // 反例,會報編譯錯誤
10 public abstract native void method5();

  識別符號native可以與所有其他的Java識別符號連用,但是abstract除外。

2、為什麼要使用Native Method?

  Java使用起來非常方便,然而有些層次的任務用Java實現起來不容易,或者對程式的效率很在意時,問題就來了。
  與Java環境外互動:有時Java應用需要與Java外面的環境互動,這是本地方法存在的主要原因。Java需要與一些底層系統,如作業系統或某些硬體交換資訊時,本地方法正是這樣一種交流機制,它為我們提供了一個非常簡潔的介面,而且我們無需去了解Java應用之外的繁瑣的細節。
  與作業系統互動:JVM支援Java語言本身和執行時庫,它是Java程式賴以生存的平臺,它由一個直譯器(解釋位元組碼)和一些連線到原生程式碼的庫組成。然而不管怎樣,它畢竟不是一個完整的系統,它經常依賴於一些底層系統的支援。這些底層系統常常是強大的作業系統。通過使用本地方法,得以用Java實現了jre的與底層系統的互動,甚至JVM的一些部分就是用C寫的。還有,如果使用一些Java語言本身沒有提供封裝的作業系統的特性時,也需要使用本地方法。
  Sun's Java:Sun的直譯器是用C實現的,這使得它能像一些普通的C一樣與外部互動。jre大部分是用Java實現的,它也通過一些本地方法與外界互動。例如:類java.lang.Thread的setPriority()方法是用Java實現的,但是它實現呼叫的是該類中的本地方法setPriority0()。這個本地方法是用C實現的,並被植入JVM內部。
  現狀:目前該方法使用的越來越少了,除非是與硬體有關的應用,比如通過Java程式驅動印表機或者Java系統管理生產裝置,在企業級應用中已經比較少見。因為現在的異構領域間的通訊很發達,比如可以使用Socket通訊,也可以使用Web Service等等。

相關文章