Java虛擬機的指令由一個字節長度的、代表着某種特定操作含義的數字(稱為操作碼,Opcode)以及跟隨其后的零至多個代表此操作所需參數(稱為操作數,Operands)而構成。
基本數據類型
1、除了long和double類型外,每個變量都占局部變量區中的一個變量槽(slot),而long及double會占用兩個連續的變量槽。
2、大多數對於boolean、byte、short和char類型數據的操作,都使用相應的int類型作為運算類型。
加載和存儲指令
1、將一個局部變量加載到操作棧:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
2、將一個數值從操作數棧存儲到局部變量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
3、將一個常量加載到操作數棧:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
4、擴充局部變量表的訪問索引的指令:wide。
_<n>:_0、_1、_2、_3,
存儲數據的操作數棧和局部變量表主要就是由加載和存儲指令進行操作,除此之外,還有少量指令,如訪問對象的字段或數組元素的指令也會向操作數棧傳輸數據。
運算指令
1、運算或算術指令用於對兩個操作數棧上的值進行某種特定運算,並把結果重新存入到操作棧頂。
2、算術指令分為兩種:整型運算的指令和浮點型運算的指令。
3、無論是哪種算術指令,都使用Java虛擬機的數據類型,由於沒有直接支持byte、short、char和boolean類型的算術指令,使用操作int類型的指令代替。
加法指令:iadd、ladd、fadd、dadd。
減法指令:isub、lsub、fsub、dsub。
乘法指令:imul、lmul、fmul、dmul。
除法指令:idiv、ldiv、fdiv、ddiv。
求余指令:irem、lrem、frem、drem。
取反指令:ineg、lneg、fneg、dneg。
位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
按位或指令:ior、lor。
按位與指令:iand、land。
按位異或指令:ixor、lxor。
局部變量自增指令:iinc。
比較指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。
類型轉換指令
1、類型轉換指令可以將兩種不同的數值類型進行相互轉換。
2、這些轉換操作一般用於實現用戶代碼中的顯式類型轉換操作,或者用來處理字節碼指令集中數據類型相關指令無法與數據類型一一對應的問題。
寬化類型轉換
int類型到long、float或者double類型。
long類型到float、double類型。
float類型到double類型。
i2l、f2b、l2f、l2d、f2d。
窄化類型轉換
i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。
對象創建與訪問指令
創建類實例的指令:new。
創建數組的指令:newarray、anewarray、multianewarray。
訪問類字段(static字段,或者稱為類變量)和實例字段(非static字段,或者稱為實例變量)的指令:getfield、putfield、getstatic、putstatic。
把一個數組元素加載到操作數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
將一個操作數棧的值存儲到數組元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
取數組長度的指令:arraylength。
檢查類實例類型的指令:instanceof、checkcast。
操作數棧管理指令
直接操作操作數棧的指令:
將操作數棧的棧頂一個或兩個元素出棧:pop、pop2。
復制棧頂一個或兩個數值並將復制值或雙份的復制值重新壓入棧頂:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
將棧最頂端的兩個數值互換:swap。
控制轉移指令
1、控制轉移指令可以讓Java虛擬機有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程序。
2、從概念模型上理解,可以認為控制轉移指令就是在有條件或無條件地修改PC寄存器的值。
條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
復合條件分支:tableswitch、lookupswitch。
無條件分支:goto、goto_w、jsr、jsr_w、ret。
在Java虛擬機中有專門的指令集用來處理int和reference類型的條件分支比較操作,為了可以無須明顯標識一個實體值是否null,也有專門的指令用來檢測null值。
方法調用和返回指令
invokevirtual 指令用於調用對象的實例方法,根據對象的實際類型進行分派(虛方法分派),這也是Java語言中最常見的方法分派方式。
invokeinterface 指令用於調用接口方法,它會在運行時搜索一個實現了這個接口方法的對象,找出適合的方法進行調用。
invokespecial 指令用於調用一些需要特殊處理的實例方法,包括實例初始化(<init>)方法、私有方法和父類方法。
invokestatic 調用靜態方法(static方法)。
invokedynamic 指令用於在運行時動態解析出調用點限定符所引用的方法,並執行該方法,前面4條調用指令的分派邏輯都固化在Java虛擬機內部,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
方法調用指令與數據類型無關,而方法返回指令是根據返回值的類型區分的,包括ireturn(當返回值是boolean、byte、char、short和int類型時使用)、lreturn、freturn、dreturn和areturn,另外還有一條return指令供聲明為void的方法、實例初始化方法以及類和接口的類初始化方法使用。
關於方法調用
1、Class文件的編譯過程中不包含傳統編譯中的連接步驟,所有方法調用中的目標方法在Class文件里面都是一個常量池中的符號引用,而不是方法在實際運行時內存布局中的入口地址。
2、在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這類方法(編譯期可知,運行期不可變)的調用稱為解析(Resolution)。
主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。
3、只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載的時候就會把符號引用解析為該方法的直接引用。
4、動態語言支持
動態類型語言的關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期。
異常處理指令
在Java程序中顯式拋出異常的操作(throw語句)都由athrow指令來實現,除了用throw語句顯式拋出異常情況之外,Java虛擬機規范還規定了許多運行時異常會在其他Java虛擬機指令檢測到異常狀況時自動拋出。
例如,在整數運算中,當除數為零時,虛擬機會在idiv或ldiv指令中拋出ArithmeticException異常。
而在Java虛擬機中,處理異常(catch語句)不是由字節碼指令來實現的(很久之前曾經使用jsr和ret指令來實現,現在已經不用了),而是采用異常表來完成的。
同步指令
Java虛擬機可以支持方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor)來支持的。
方法級同步
方法級的同步是隱式的,即無須通過字節碼指令來控制
它實現在方法調用和返回操作之中。虛擬機可以從方法常量池的方法表結構中的ACC_SYNCHRONIZED訪問標志得知一個方法是否聲明為同步方法。當方法調用時,調用指令將會檢查方法的ACC_SYNCHRONIZED訪問標志是否被設置,如果設置了,執行線程就要求先成功持有管程,然后才能執行方法,最后當方法完成(無論是正常完成還是非正常完成)時釋放管程。在方法執行期間,執行線程持有了管程,其他任何線程都無法再獲取到同一個管程。如果一個同步方法執行期間拋出了異常,並且在方法內部無法處理此異常,那么這個同步方法所持有的管程將在異常拋到同步方法之外時自動釋放。
方法內部一段指令序列的同步
同步一段指令集序列通常是由Java語言中的synchronized語句塊來表示的,Java虛擬機的指令集中有monitorenter和monitorexit兩條指令來支持synchronized關鍵字的語義,正確實現synchronized關鍵字需要Javac編譯器與Java虛擬機兩者共同協作支持。
虛擬機運行活化的內存數據中的指令:程序的執行
前面我們說明了java源碼被編譯成為了二進制字節碼,二進制字節碼轉為內存中方法區里存儲的活化對象,那么最重要的程序執行就做好了基礎:當方法區里的字段和方法按照虛擬機規定的數據結構排好,常量池中的符號引用數據在加載過程中最大限度地轉為了直接引用,那么這個時候虛擬機就可以在加載主類后創建新的線程按步執行主類的main函數中的指令了。
java虛擬機執行程序的基礎是特定的二進制指令集和運行時棧幀:
-
二進制指令集是java虛擬機規定的一些指令,在編譯后二進制字節碼的類方法里的字節碼就是這種指令,所以只要找到方法區里的類方法就可以依照這套指令集去執行命令。
-
運行時棧幀是虛擬機執行的物理所在,在這個棧幀結構上,方法的局部變量、操作數棧、動態鏈接和返回地址依序排列,依照命令動態變換棧幀上的數據,最終完成所有的這個方法上的指令。
棧幀的進一步划分:
-
局部變量表:包括方法的參數和方法體內部的局部變量都會存在這個表中。
-
操作數棧:操作數棧是一個運行中間產生的操作數構成的棧,這個棧的棧頂保存的就是當前活躍的操作數。
-
動態鏈接:我們之前提到這個方法中調用的方法和類在常量池中的符號引用轉換為的直接引用就保存在這里,只要訪問到這些方法和類的時候就會根據動態鏈接去直接引用所指的地址加載那些方法。
-
返回地址:程序正常結束恢復上一個棧幀的狀態的時候需要知道上一個指令的地址。
現在我們使用一個綜合實例來說明運行的整個過程:
源代碼如下,邏輯很簡單:
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虛擬機的動態分派會在分派前確定對象的實際類型,面向對象的多態性才能體現出來。
對象的創建和堆內存的分配
前面我們提到的都是類在方法區中的內存分配:
在方法區中有類的常量池,常量池中保存着類的很多信息的符號引用,很多符號引用還轉換為了直接引用以使在運行的過程能夠訪問到這些信息的真實地址。
那么創建出的對象是怎么在堆中分配空間的呢?
首先我們要明確對象中存儲的大部分的數據就是它對應的非靜態字段和每個字段方法對應的方法區中的地址,因為這些東西每個對象都是不一樣的,所以必須通過各自的堆空間存儲這些不一樣的數據,而方法是所有同類對象共用的,因為方法的命令是一樣的,每個對象只是在各自的線程棧幀里提供各自的局部變量表和操作數棧就好。
這樣看來,堆中存放的是真正“有個性”的屬於對象自己的變量,這些變量往往是最占空間的,而這些變量對應的類字段的地址會找到位於方法區中,同樣的同類對象如果要執行一個方法只需要在自己的棧幀里面創建局部變量表和操作數棧,然后根據方法對應的方法區中的地址去尋找到方法體執行其中的命令即可,這樣一來堆里面只存放有限的真正有意義的數據和地址,方法區里存放共用的字段和方法體,能最大程度地減小內存開銷。