這篇文章的素材來自周志明的《深刻理解Java虛擬機》。java
做爲Java開發人員,必定程度瞭解JVM虛擬機的的運做方式很是重要,本文就一些簡單的虛擬機的相關概念和運做機制展開我本身的學習過程,是這個系列的第三篇。數據結構
前面咱們說明了java源碼被編譯成爲了二進制字節碼,二進制字節碼轉爲內存中方法區裏存儲的活化對象,那麼最重要的程序執行就作好了基礎:當方法區裏的字段和方法按照虛擬機規定的數據結構排好,常量池中的符號引用數據在加載過程當中最大限度地轉爲了直接引用,那麼這個時候虛擬機就能夠在加載主類後建立新的線程按步執行主類的main函數中的指令了。ide
java虛擬機執行程序的基礎是特定的二進制指令集和運行時棧幀:函數
二進制指令集是java虛擬機規定的一些指令,在編譯後二進制字節碼的類方法裏的字節碼就是這種指令,因此只要找到方法區裏的類方法就能夠依照這套指令集去執行命令。工具
運行時棧幀是虛擬機執行的物理所在,在這個棧幀結構上,方法的局部變量、操做數棧、動態連接和返回地址依序排列,依照命令動態變換棧幀上的數據,最終完成全部的這個方法上的指令。學習
棧幀的進一步劃分:this
局部變量表:包括方法的參數和方法體內部的局部變量都會存在這個表中。spa
操做數棧:操做數棧是一個運行中間產生的操做數構成的棧,這個棧的棧頂保存的就是當前活躍的操做數。線程
動態連接:咱們以前提到這個方法中調用的方法和類在常量池中的符號引用轉換爲的直接引用就保存在這裏,只要訪問到這些方法和類的時候就會根據動態連接去直接引用所指的地址加載那些方法。code
返回地址:程序正常結束恢復上一個棧幀的狀態的時候須要知道上一個指令的地址。
如今咱們使用一個綜合實例來講明運行的整個過程:
源代碼以下,邏輯很簡單:
public class TestDemo { public static int minus(int x){ return -x; } public static void main(String[] args) { int x = 5; int y = minus(x); } }
咱們能夠分析它的二進制字節碼,固然這裏咱們藉助javap工具進行分析:
jinhaoplus$ javap -verbose TestDemo Classfile /Users/jinhao/Desktop/TestDemo.class Last modified 2015-10-17; size 342 bytes MD5 checksum 4f37459aa1b3438b1608de788d43586d Compiled from "TestDemo.java" public class TestDemo minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#15 // java/lang/Object."<init>":()V #2 = Methodref #3.#16 // TestDemo.minus:(I)I #3 = Class #17 // TestDemo #4 = Class #18 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 minus #10 = Utf8 (I)I #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 TestDemo.java #15 = NameAndType #5:#6 // "<init>":()V #16 = NameAndType #9:#10 // minus:(I)I #17 = Utf8 TestDemo #18 = Utf8 java/lang/Object { public TestDemo(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 1: 0 public static int minus(int); descriptor: (I)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iload_0 1: ineg 2: ireturn LineNumberTable: line 3: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 0: iconst_5 1: istore_1 2: iload_1 3: invokestatic #2 // Method minus:(I)I 6: istore_2 7: return LineNumberTable: line 6: 0 line 7: 2 line 8: 7 } SourceFile: "TestDemo.java"
這個過程是從固化在class文件中的二進制字節碼開始,通過加載器對當前類的加載,虛擬機對二進制碼的驗證、準備和必定的解析,進入內存中的方法區,常量池中的符號引用必定程度上轉換爲直接引用,使得字節碼經過結構化的組織讓虛擬機瞭解類的每一塊的構成,建立的線程申請到了虛擬機棧中的空間構造出屬於這一線程的棧幀空間,執行主類的main方法:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 0: iconst_5 1: istore_1 2: iload_1 3: invokestatic #2 // Method minus:(I)I 6: istore_2 7: return LineNumberTable: line 6: 0 line 7: 2 line 8: 7 }
首先檢查main的訪問標誌、描述符描述的返回類型和參數列表,肯定能夠訪問後進入Code屬性表執行命令,讀入棧深度創建符合要求的操做數棧,讀入局部變量大小創建符合要求的局部變量表,根據參數數向局部變量表中依序加入參數(第一個參數是引用當前對象的this,因此空參數列表的參數數也是1),而後開始根據命令正式執行:
0: iconst_5
將整數5壓入棧頂
1: istore_1
將棧頂整數值存入局部變量表的slot1(slot0是參數this)
2: iload_1
將slot1壓入棧頂
3: invokestatic #2 // Method minus:(I)I
二進制invokestatic方法用於調用靜態方法,參數是根據常量池中已經轉換爲直接引用的常量,意即minus函數在方法區中的地址,找到這個地址調用函數,向其中加入的參數爲棧頂的值
6: istore_2
將棧頂整數存入局部變量的slot2
7: return
將返回地址中存儲的PC地址返到PC,棧幀恢復到調用前
如今咱們分析調用minus函數的時候進入minus函數的過程:
public static int minus(int); descriptor: (I)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iload_0 1: ineg 2: ireturn LineNumberTable: line 3: 0
一樣的首先檢查minus函數的訪問標誌、描述符描述的返回類型和參數列表,肯定能夠訪問後進入Code屬性表執行命令,讀入棧深度創建符合要求的操做數棧,讀入局部變量大小創建符合要求的局部變量表,根據參數數向局部變量表中依序加入參數,而後開始根據命令正式執行:
0: iload_0
將slot0壓入棧頂,也就是傳入的參數
1: ineg
將棧頂的值彈出取負後壓回棧頂
2: ireturn
將返回地址中存儲的PC地址返到PC,棧幀恢復到調用前
這個過程結束後對象的生命週期結束,所以開始執行GC回收內存中的對象,包括堆中的類對應的java.lang.Class對象,卸載方法區中的類。
上面這個例子中main方法裏調用minus方法的時候是沒有二義性的,由於從二進制字節碼裏咱們能夠看到invokestatic方法調用的是minus方法的直接引用,也就說在編譯期這個調用就已經決定了。這個時候咱們來講說方法調用,這個部分的內容在前面的類加載時候提過,在可以惟一肯定方法的直接引用的時候虛擬機會將常量表裏的符號引用轉換爲直接引用,這樣在運行的時候就能夠直接根據這個地址找到對應的方法去執行,這種時候的轉換才能叫作咱們當時提到的在鏈接過程當中的解析。
可是若是方法是動態綁定的,也就是說在編譯期咱們並不知道使用哪一個方法(或者叫不知道使用方法的哪一個版本),那麼這個時候就須要在運行時才能肯定哪一個版本的方法將被調用,這個時候才能將符號引用轉換爲直接引用。這個問題提到的多個版本的方法在java中的重載和多態重寫問題息息相關。
重載(override)
public class TestDemo { static class Human{ } static class Man extends Human{ } static class Woman extends Human{ } public void sayHello(Human human) { System.out.println("hello human"); } public void sayHello(Man man) { System.out.println("hello man"); } public void sayHello(Woman woman) { System.out.println("hello woman"); } public static void main(String[] args) { TestDemo demo = new TestDemo(); Human man = new Man(); Human woman = new Woman(); demo.sayHello(man); demo.sayHello(woman); } }
javap結果:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: new #7 // class TestDemo 3: dup 4: invokespecial #8 // Method "<init>":()V 7: astore_1 8: new #9 // class TestDemo$Man 11: dup 12: invokespecial #10 // Method TestDemo$Man."<init>":()V 15: astore_2 16: new #11 // class TestDemo$Woman 19: dup 20: invokespecial #12 // Method TestDemo$Woman."<init>":()V 23: astore_3 24: aload_1 25: aload_2 26: invokevirtual #13 // Method sayHello:(LTestDemo$Human;)V 29: aload_1 30: aload_3 31: invokevirtual #13 // Method sayHello:(LTestDemo$Human;)V 34: return LineNumberTable: line 21: 0 line 22: 8 line 23: 16 line 24: 24 line 25: 29 line 26: 34
重寫(overwrite)
public class TestDemo { static class Human{ public void sayHello() { System.out.println("hello human"); } } static class Man extends Human{ public void sayHello() { System.out.println("hello man"); } } static class Woman extends Human{ public void sayHello() { System.out.println("hello woman"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); } }
javap結果:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: new #2 // class TestDemo$Man 3: dup 4: invokespecial #3 // Method TestDemo$Man."<init>":()V 7: astore_1 8: new #4 // class TestDemo$Woman 11: dup 12: invokespecial #5 // Method TestDemo$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #6 // Method TestDemo$Human.sayHello:()V 20: aload_2 21: invokevirtual #6 // Method TestDemo$Human.sayHello:()V 24: return LineNumberTable: line 20: 0 line 21: 8 line 22: 16 line 23: 20 line 24: 24
咱們能夠看出來不管是重載仍是重寫,都是二進制指令invokevirtual調用了sayHello方法來執行的。
在重載中,程序調用的是參數實際類型不一樣的方法,可是虛擬機最終分派了相同外觀類型(靜態類型)的方法,這說明在重載的過程當中虛擬機在運行的時候是隻看參數的外觀類型(靜態類型)的,而這個外觀類型(靜態類型)是在編譯的時候就已經肯定的,和虛擬機沒有關係。這種依賴靜態類型來作方法的分配叫作靜態分派。
在重寫中,程序調用的是不一樣實際類型的同名方法,虛擬機依據對象的實際類型去尋找是否有這個方法,若是有就執行,若是沒有去父類裏找,最終在實際類型裏找到了這個方法,因此最終是在運行期動態分派了方法。在編譯的時候咱們能夠看到字節碼指示的方法都是同樣的符號引用,可是運行期虛擬機可以根據實際類型去肯定出真正須要的直接引用。這種依賴實際類型來作方法的分配叫作動態分派。得益於java虛擬機的動態分派會在分派前肯定對象的實際類型,面向對象的多態性才能體現出來。
前面咱們提到的都是類在方法區中的內存分配:
在方法區中有類的常量池,常量池中保存着類的不少信息的符號引用,不少符號引用還轉換爲了直接引用以使在運行的過程可以訪問到這些信息的真實地址。
那麼建立出的對象是怎麼在堆中分配空間的呢?
首先咱們要明確對象中存儲的大部分的數據就是它對應的非靜態字段和每一個字段方法對應的方法區中的地址,由於這些東西每一個對象都是不同的,因此必須經過各自的堆空間存儲這些不同的數據,而方法是全部同類對象共用的,由於方法的命令是同樣的,每一個對象只是在各自的線程棧幀裏提供各自的局部變量表和操做數棧就好。
這樣看來,堆中存放的是真正「有個性」的屬於對象本身的變量,這些變量每每是最佔空間的,而這些變量對應的類字段的地址會找到位於方法區中,一樣的同類對象若是要執行一個方法只須要在本身的棧幀裏面建立局部變量表和操做數棧,而後根據方法對應的方法區中的地址去尋找到方法體執行其中的命令便可,這樣一來堆裏面只存放有限的真正有意義的數據和地址,方法區裏存放共用的字段和方法體,能最大程度地減少內存開銷。