深入理解JVM-位元組碼執行引擎
前面我們不止一次的提到,Java是一種跨平臺的語言,為什麼可以跨平臺,因為我們編譯的結果是中間程式碼—位元組碼,而不是機器碼,那位元組碼在整個Java平臺扮演著什麼樣的角色的呢?JDK1.2之前對應的結構圖如下所示:
從JDK1.2開始,迫於Java執行始終比C++慢的壓力,JVM的結構也慢慢發生了一些變化,JVM在某些場景下可以操作一定的硬體平臺,一些核心的Java庫甚至也可以操作底層的硬體平臺,從而大大提升了Java的執行效率,在前面JVM記憶體模型和垃圾回收中也給大家演示瞭如何操作實體記憶體,下圖展示了JDK1.2之後的JVM結構模型。
那C++和Java在編譯和執行時到底有啥不一樣?為啥Java
我們從上圖可以看出。
C++釋出的就是機器指令,而Java釋出的是位元組碼,位元組碼在執行時通過JVM做一次轉換生成機器指令,因此能夠更好的跨平臺執行。如圖所示,展示了對應程式碼從編譯到執行的一個效果圖。
我們知道JVM是基於棧執行的,每個執行緒會建立一個操作棧,每個棧又包含了若干個棧幀,每個棧幀包含了三部分:區域性變數區、運算元棧區、執行環境區(動態連線、正確的方法返回的相關資訊,異常捕捉)。其實在我們編譯的時候,需要多大的區域性變量表、運算元深度等已經確定並寫入了Code屬性,因此執行時記憶體消耗的大小在啟動時已經已知。
在棧幀中,最小的單位為變數槽(Variable Slot),
在棧幀中,區域性變數區中的Slot是可以複用的,如在一個方法返回給上一個方法是就可以通過公用Slot
Slot複用會給JVM的垃圾回收帶來一定影響,如下程式碼:
1 package com.yhj.jvm.byteCode.slotFree; 2 3 /** 4 5 * @Described:Slot區域性變量表 沒有破壞GCRoot情況演示 6 7 * @VM params :-XX:+PrintGCDetails -verbose:gc 8 9 * @author YHJ create at 2012-2-22 下午04:37:29 10 11 * @FileNmae com.yhj.jvm.byteCode.slotFree.SlotFreeTestCase.java 12 13 */ 14 15 public class SlotFreeTestCase { 16 17 /** 18 19 * @param args 20 21 * @Author YHJ create at 2012-2-22 下午04:37:25 22 23 */ 24 25 @SuppressWarnings("unused") 26 27 public static void main(String[] args) { 28 29 //case 1 30 31 byte[] testCase = new byte[10*1024*1024]; 32 33 System.gc(); 34 35 // //case 2 36 37 // { 38 39 // byte[] testCase = new byte[10*1024*1024]; 40 41 // } 42 43 // System.gc(); 44 45 // //case 3 46 47 // { 48 49 // byte[] testCase = new byte[10*1024*1024]; 50 51 // } 52 53 // int a = 0; 54 55 // System.gc(); 56 57 // //case 5 58 59 // byte[] testCase = new byte[10*1024*1024]; 60 61 // testCase=null; 62 63 // System.gc(); 64 65 } 66 }
如上所示,當我們執行這段程式碼的時候並不會引發GC的回收,因為很簡單,我的testCase物件還在使用中,生命週期並未結束,因此執行結果如下
但是我們換下面的case2這種寫法呢?
1 //case 2 2 3 { 4 5 byte[] testCase = new byte[10*1024*1024]; 6 7 } 8 9 System.gc();
這種寫法,testCase在大括號中生命週期已經結束了,會不會引發GC的呢?我們來看結果:
我們可以看到仍然沒有進行回收。那我變通一下,再定義一個變數會怎麼樣的呢?1 //case 3 2 3 { 4 5 byte[] testCase = new byte[10*1024*1024]; 6 7 } 8 9 int a = 0; 10 11 System.gc();
這下我們貌似看到奇蹟了
沒錯,
JVM做了回收操作,因為JVM在做下面的操作時並沒有發現公用的Slot,因此該記憶體區域被回收。但是我們這樣寫程式碼會讓很多人感到迷惑,我們應該怎樣寫才能更好一些讓人理解呢?
1 //case 5 2 3 byte[] testCase = new byte[10*1024*1024]; 4 5 testCase=null; 6 7 System.gc();
無疑,這樣寫才是最好的,這也是書本effective Java中強調了很多遍的寫法,隨手置空不用的物件。
我們知道private int a;這麼一個語句在一個類中的話他的預設值是0,那麼如果是在區域性變數中的呢?我們開看這樣一段程式碼:
1 package com.yhj.jvm.byteCode.localVariableInit; 2 3 /** 4 5 * @Described:區域性變數拒絕預設初始化 6 7 * @author YHJ create at 2012-2-24 下午08:40:34 8 9 * @FileNmae com.yhj.jvm.byteCode.localVariableInit.LocalVariableInit.java 10 11 */ 12 13 public class LocalVariableInit { 14 15 16 17 /** 18 19 * @param args 20 21 * @Author YHJ create at 2012-2-22 下午05:12:06 22 23 */ 24 25 @SuppressWarnings("unused") 26 27 public static void main(String[] args) { 28 29 int a; 30 31 System.out.println(a); 32 33 } 34 35 }
這段程式碼的運營結果又是什麼的呢?
很多人會回答0.我們來看一下執行結果:
沒錯,就是報錯了,如果你使用的是
Eclipse這種高階一點的IDE的 話,在編譯階段他就會提示你,該變數沒有初始化。原因是什麼的呢?原因就是,區域性變數並沒有類例項變數那樣的連結過程,前面我們說過,類的載入分為三個階段:載入(loading)、 連結(linking)、初始化(initializing),其中連結(linking)分為三個階段:驗證(verify)、準備(prepare)、解析(resolve),而驗證(verify)是確保類載入的正確性、準備(prepare)是為類的靜態變數分配記憶體,並初始化為預設值、解析(resolve)是把 類中的符號引用轉換為直接引用。而初始化(initializing)是為類的靜態變數賦值為正確顯示定義的值。而區域性變數並沒有連結(linking)的階段,因此沒有賦值為預設值這一階段,因此必須自己 初始化才能使用。
我們在類的載入中提到類的靜態連結過程,但是還有一部分類是需要動態連結的,其中以下是需要動態連結的物件
1、 例項變數(類的變數或者區域性變數)
2、 通過其他容器報告動態注入的變數(IOC)
3、 通過程式碼注入的物件(void setObj(Object obj))
所有的動態連結都只有準備和解析階段,沒有再次校驗(校驗發生在連結前類的載入階段),其中區域性變數不會再次引發準備階段。
前面我們提到JVM的生命週期,在以下四種情況下會引發JVM的生命週期結束
1、 執行了System.exit()方法
2、 程式正常執行結束
3、 程式在執行過程中遇到了異常或者錯誤導致異常終止
4、 由於作業系統出現錯誤而導致JVM程序終止
同樣,在以下情況下會導致一個方法呼叫結束
1、 執行引擎遇到了方法返回的位元組碼指令
2、 執行引擎在執行過程中遇到了未在該方法內捕獲的異常
這時候很多人會有一個疑問:當程式返回之後它怎麼知道繼續在哪裡執行?這就用到了我們JVM記憶體模型中提到了的PC計數器。方法退出相當於當前棧出棧,出棧後主要做了以下事情:
1、 恢復上層方法的區域性變量表
2、 如果有返回值的話將返回值壓入到上層運算元棧
3、 調整PC計數器指向下一條指令
除了以上資訊以外,棧幀中還有一些附加資訊,如預留一部分記憶體用於實現一些特殊的功能,如除錯資訊,遠端監控等資訊。
接下來我們要說的是方法呼叫,方法呼叫並不等於方法執行,方法呼叫的任務是確定呼叫方法的版本(呼叫哪一個方法),在實際過程中有可能發生在載入期也有可能發生在執行期。Class的編譯過程並不包含類似C++的連結過程,只有在類的載入期或者執行期才將對應的符號引用修正為真正的直接引用,大大的提升了Java的靈活性,但是也大大增加了Java的複雜性。
在類載入的第二階段連結的第三階段解析,這一部分是在編譯時就確定下來的,屬於編譯期可知執行期不可變。在位元組碼中主要包含以下兩種
1、 invokestatic 主要用於呼叫靜態方法,屬於繫結類的呼叫
2、 invokespecial 主要用於呼叫私有方法,外部不可訪問,繫結例項物件
還有一種是在執行時候解析的,只有在執行時才能確定下來的,主要包含以下兩方面
1、 invokevirtual 呼叫虛方法,不確定呼叫那一個實現類
2、 invokeinterface 呼叫介面方法,不確定呼叫哪一個實現類
我們可以通過javap的命令檢視對應的位元組碼檔案方法呼叫的方式,如下圖所示
Java
方法在呼叫過程中,把invokestatic和invokespecial定義為非虛方法的呼叫,非虛方法的呼叫都是在編譯器已經確定具體要呼叫哪一個方法,在類的載入階段就完成了符號引用到直接引用的轉換。除了非虛方法以外,還有一種被final修飾的方法,因被final修飾以後呼叫無法通過其他版本來覆蓋,因此被final修飾的方法也是在編譯的時候就已知的非虛方法。
除了解析,Java中還有一個概念叫分派,分派是多型的最基本表現形式,可分為單分派、多分派兩種;同時分派又可以分為靜態分派和動態分派,因此一組合,可以有四種組合方式。其實最本質的體現就是方法的過載和重寫。我們來看一個例子
1 package com.yhj.jvm.byteCode.staticDispatch; 2 3 /** 4 5 * @Described:靜態分配 6 7 * @author YHJ create at 2012-2-24 下午08:20:06 8 9 * @FileNmae com.yhj.jvm.byteCode.staticDispatch.StaticDispatch.java 10 11 */ 12 13 public class StaticDispatch { 14 15 16 17 static abstract class Human{}; 18 19 static class Man extends Human{} ; 20 21 static class Woman extends Human{} ; 22 23 24 25 public void say(Human human) { 26 27 System.out.println("hi,you are a good human!"); 28 29 } 30 31 public void say(Man human) { 32 33 System.out.println("hi,gentleman!"); 34 35 } 36 37 public void say(Woman human) { 38 39 System.out.println("hi,yong lady!"); 40 41 } 42 43 /** 44 45 * @param args 46 47 * @Author YHJ create at 2012-2-24 下午08:20:00 48 49 */ 50 51 public static void main(String[] args) { 52 53 Human man = new Man(); 54 55 Human woman = new Woman(); 56 57 StaticDispatch dispatch = new StaticDispatch(); 58 59 dispatch.say(man); 60 61 dispatch.say(woman); 62 63 } 64 65 }
這個例子的執行結果會是什麼呢?我們來看一下結果
和你的預期一致麼?這個其實是一個靜態分派的杯具,
man和woman兩個物件被轉型以後,通過特徵簽名匹配,只能匹配到對應的父類的過載方法,因此導致最終的結構都是執行父類的程式碼。因為具體的類是在執行期才知道具體是什麼型別,而編譯器只確定是Human這種型別的資料。
這種寫法曾經在我們專案中也發生過一次。如下程式碼所示
1 package com.yhj.jvm.byteCode.staticDispatch; 2 3 import java.util.ArrayList; 4 5 import java.util.List; 6 7 /** 8 9 * @Described:蝌蚪網曾經的杯具 10 11 * @author YHJ create at 2012-2-26 下午09:43:20 12 13 * @FileNmae com.yhj.jvm.byteCode.staticDispatch.CothurnusInPassport.java 14 15 */ 16 17 public class CothurnusInPassport { 18 19 /** 20 21 * 主函式入口 22 23 * @param args 24 25 * @Author YHJ create at 2012-2-26 下午09:48:02 26 27 */ 28 29 public static void main(String[] args) { 30 31 List<CothurnusInPassport> inPassports = new ArrayList<CothurnusInPassport>(); 32 33 inPassports.add(new CothurnusInPassport()); 34 35 String xml = XML_Util.createXML(inPassports); 36 37 System.out.println(xml); 38 39 } 40 41 } 42 43 class XML_Util{ 44 45 public static String createXML(Object obj){ 46 47 return 。。。// ... 通過反射遍歷屬性 生成對應的XML節點 48 49 } 50 51 public static String createXML(List<Object> objs){ 52 53 StringBuilder sb = new StringBuilder(); 54 55 for(Object obj : objs) 56 57 sb.append(createXML(obj)); 58 59 return new String(sb); 60 61 } 62 63 }
當時我們專案組寫了以惡搞XML_Util的一個類用於生成各種XML資料,其中一個例項傳入的引數是Object,一個是一個List型別的資料,如上面程式碼所示,我的呼叫結果會執行哪一個的呢?結果大家已經很清楚了,他呼叫了createXML(Object obj)這個方法,因此生成過程中老是報錯,原因很簡單,就是因為我呼叫的時候泛型 不匹配,進行了隱式的型別轉換,因此無法匹配到對應的List<Object>最終呼叫了createXML(Object obj)這個方法。
下面我們來看一道噁心的面試題,程式碼如下:
1 package com.yhj.jvm.byteCode.polymorphic; 2 3 import java.io.Serializable; 4 5 /** 6 7 * @Described:過載測試 8 9 * @author YHJ create at 2012-2-24 下午08:41:12 10 11 * @FileNmae com.yhj.jvm.byteCode.polymorphic.OverLoadTestCase.java 12 13 */ 14 15 public class OverLoadTestCase { 16 17 public static void say(Object obj){ System.out.println("Object"); } 18 19 public static void say(char obj){ System.out.println("char"); } 20 21 public static void say(int obj){ System.out.println("int"); } 22 23 public static void say(long obj){ System.out.println("long"); } 24 25 public static void say(float obj){ System.out.println("float"); } 26 27 public static void say(double obj){ System.out.println("double"); } 28 29 public static void say(Character obj){ System.out.println("Character"); } 30 31 public static void say(Serializable obj){ System.out.println("Serializable"); } 32 33 public static void say(char... obj){ System.out.println("char..."); } 34 35 public static void main(String[] args) { 36 37 OverLoadTestCase.say('a'); 38 39 } 40 41 }
這樣的程式碼會執行什麼呢?這個很簡單的了,是char,那如果我註釋掉char這個方法,再執行呢?是int,繼續註釋,接下來是什麼的呢?大家可以自己測試一下,你會發現這段程式碼有多麼的噁心。
我們接下來再看一段程式碼:
1 package com.yhj.jvm.byteCode.dynamicDispatch; 2 3 /** 4 5 * @Described:動態分派測試 6 7 * @author YHJ create at 2012-2-26 下午10:05:43 8 9 * @FileNmae com.yhj.jvm.byteCode.dynamicDispatch.DynamicDispatch.java 10 11 */ 12 13 public class DynamicDispatch { 14 15 static abstract class Human{ 16 17 public abstract void say(); 18 19 }; 20 21 static class Man extends Human{ 22 23 @Override 24 25 public void say(){ 26 27 System.out.println("hi,you are a good man!"); 28 29 } 30 31 } ; 32 33 static class Woman extends Human{ 34 35 @Override 36 37 public void say(){ 38 39 System.out.println("hi,young lady!"); 40 41 } 42 43 } ; 44 45 //主函式入口 46 47 public static void main(String[] args) { 48 49 Human man = new Man(); 50 51 Human woman = new Woman(); 52 53 man.say(); 54 55 woman.say(); 56 57 woman = new Man(); 58 59 woman.say(); 60 61 } 62 63 }
這段程式碼執行的結果會是什麼的呢?這個不用說了吧?企業級的應用經常會使用這些方法重寫,這是動態分配的一個具體體現,也就是說只有執行期才知道具體執行的是哪一個類,在編譯期前並不知道會呼叫哪一個類的這個方法執行。
我們再來看一段程式碼,這段程式碼被稱為“一個艱難的決定”
1 //動態單分派靜態多分派 宗量選擇 2 3 package com.yhj.jvm.byteCode.dynamicOneStaticMoreDispatch; 4 5 /** 6 7 * @Described:一個艱難的決定 8 9 * @author YHJ create at 2012-2-24 下午09:23:26 10 11 * @FileNmae com.yhj.jvm.byteCode.dynamicOneStaticMore.OneHardMind.java 12 13 */ 14 15 public class OneHardMind { 16 static class QQ{} //騰訊QQ 17 18 static class _360{} //360安全衛士 19 20 static class QQ2011 extends QQ{} //騰訊QQ2011 21 22 static class QQ2012 extends QQ{} //騰訊QQ2012 23 24 //百度 25 26 static class BaiDu{ 27 28 public static void choose(QQ qq){ System.out.println("BaiDu choose QQ"); } 29 30 public static void choose(QQ2011 qq){ System.out.println("BaiDu choose QQ2011"); } 31 32 public static void choose(QQ2012 qq){ System.out.println("BaiDu choose QQ2012"); } 33 34 public static void choose(_360 _){ System.out.println("BaiDu choose 360 safe"); } 35 36 } 37 38 //迅雷 39 40 static class Thunder{ 41 42 public static void choose(QQ qq){ System.out.println("Thunder choose QQ"); } 43 44 public static void choose(QQ2011 qq){ System.out.println("Thunder choose QQ2011"); } 45 46 public static void choose(QQ2012 qq){ System.out.println("Thunder choose QQ2012"); } 47 48 public static void choose(_360 qq){ System.out.println("Thunder choose 360 safe"); } 49 50 } 51 52 //主函式入口 53 54 @SuppressWarnings("static-access") 55 56 public static void main(String[] args) { 57 58 BaiDu baiDu = new BaiDu(); 59 60 Thunder thunder = new Thunder(); 61 62 QQ qq = new QQ(); 63 64 _360 _360_safe = new _360(); 65 66 baiDu.choose(qq); 67 68 thunder.choose(_360_safe); 69 70 qq = new QQ2011(); 71 72 baiDu.choose(qq); 73 74 qq = new QQ2012(); 75 76 baiDu.choose(qq); 77 78 } 79 }
這段程式碼的執行結果又是什麼?現在可以很簡單的說出對應的結果了吧!
從這個例子我們可以看出,
Java是靜態多分派動態單分派的 同理,C#3.0、C++也是靜態多分配,動態單分派的C#4.0後引入型別dynamic可以實現動態多分派,sun公司在JSR-292中提出了動態多分派的實現,規劃在JDK1.7推出,但是被oracle收購後,截至目前,JDK1.7已經發布了多個版本,但尚未實現動態多分派。至於動態多分派究竟是怎麼樣子的?我們可以參考Python的多分派例項。
那虛擬機器為什麼能夠實現不同的類載入不同的方法,什麼時候使用靜態分派?什麼時候又使用動態分派呢?我們把上面的示例用一個圖來表示,大家就很清楚了!
當 子類有重寫父類的方法時,在系統進行解析的時候