Java 虛擬機器類載入機制和位元組碼執行引擎

jiangmitiao的部落格發表於2015-07-29

引言

我們知道java程式碼編譯後生成的是位元組碼,那虛擬機器是如何載入這些class位元組碼檔案的呢?載入之後又是如何進行方法呼叫的呢?

一 類檔案結構

無關性基石

java有一個口號叫做一次編寫,到處執行。實現這個口號的就是可以執行在不同平臺上的虛擬機器和與平臺無關的位元組碼。這裡要注意的是,虛擬機器也是中立的,只要是符合規範的位元組碼,都可以被虛擬機器接受,例如Groovy,JRuby等語言,都會生成符合規範的位元組碼,然後被虛擬機器所執行,虛擬機器不關心位元組碼由哪種語言生成。

類檔案結構

class類檔案是一組以8位位元組為基礎的二進位制流,它包含以下幾個部分:

魔數和class檔案版本:類檔案開頭的四個位元組被定義為CAFEBABE,只有開頭為CAFEBABE的檔案才可以被虛擬機器接受,接下來四個位元組為class檔案的版本號,高版本JDK可以相容以前版本的class檔案,但不能執行以後版本的class檔案。

常量池:可以理解為class檔案中的資源倉庫,它包含兩大類常量:字面量和符號引用,字面量包含文字字串,宣告為final的常量值等,符號引用包含類和介面的全限定名,欄位的名稱和描述符,方法的名稱和描述符。

訪問標誌:常量池結束後,緊接著兩個位元組表示訪問標誌,用於識別一些類或介面層次的訪問資訊,例如是否是public,是否是static等。

類索引,父類索引,和介面索引集合:類索引用來確定這個類的全限定名,父類為父類的全限定名,介面索引集合為介面的全限定名。

欄位表集合:用於描述介面或者類中宣告的變數,但不包含方法中的變數。

方法表集合:用於表述介面或者類中的方法。

屬性表集合:class檔案,欄位表,方法表中的屬性都源自這裡。

二  類載入機制

虛擬機器把描述類的資料從class檔案載入到記憶體,並對資料進行校驗,轉換分析和初始化,最終形成可以被虛擬節直接使用的JAVA型別,這就是虛擬機器的類載入機制。

類從被載入到虛擬機器記憶體到解除安裝出記憶體的生命週期包括:載入->連線(驗證->準備->解析)->初始化->使用->解除安裝。

初始化的5種情況:

  1. 使用new關鍵字例項化物件時,讀取或設定一個類的靜態欄位,除被final修飾經編譯結果放在常量池的靜態欄位,呼叫類的靜態方法時。
  2. 使用java.lang.reflect包方法對類進行反射呼叫時。(Class.forName())。
  3. 初始化子類時,如果父類沒有初始化。
  4. 虛擬機器啟動時main方法所在的類。
  5. 當使用JDK1.7動態語言支援時,java.lang.invoke.MethodHandle例項解析結果為REF_getStatic,REF_putStatic,REF_invokeStatic的方法控制程式碼,且對應類沒有進行初始化。

類載入過程

載入

載入是類載入的第一個階段,虛擬機器要完成以下三個過程:1)通過類的全限定名獲取定義此類的二進位制位元組流。2)將位元組流的儲存結構轉化為方法區的執行時結構。3)在記憶體中生成一個代表該類的Class物件,作為方法區各種資料的訪問入口。

驗證

目的是確保class檔案位元組流資訊符合虛擬機器的要求。

準備

為static修飾的變數賦初值,例如int型預設為0,boolean預設為false。

解析

虛擬機器將常量池內的符號引用替換成直接引用。

初始化

初始化是類載入的最後一個階段,將執行類構造器<init>()方法,注意這裡的方法不是構造方法。該方法將會顯式呼叫父類構造器,接下來按照java語句順序為類變數和靜態語句塊賦值。

類載入器

對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在java虛擬機器中的唯一性。舉一個例子:

package com.sinaapp.gavinzhang.bean;
import java.io.InputStream;

public class App 
{
    public static void main( String[] args )
    {
        ClassLoader myClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try{
                    String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if(is == null)
                    {
                        System.out.println(fileName+ "is not find");
                        return super.loadClass(name);
                    }
                    System.out.println("fileName: "+fileName);
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name,b,0,b.length);
                }catch (Exception E)
                {
                    throw new ClassCastException(name);
                }

            }
        };
        try {
            Object obj = myClassLoader.loadClass("com.sinaapp.gavinzhang.bean.Resource").newInstance();
            Object obj1  = Class.forName("com.sinaapp.gavinzhang.bean.Resource").newInstance();
            System.out.println(obj instanceof com.sinaapp.gavinzhang.wesound.bean.Resource);
            System.out.println(obj1 instanceof com.sinaapp.gavinzhang.wesound.bean.Resource);
        }catch (Exception e)
        {
            e.printStackTrace();
        }
    }
}

結果為:

JAVA 虛擬機器類載入機制和位元組碼執行引擎

可以看到,由自定義的載入類只能獲取同包下的class,而系統的class不能被載入,而且由Class.forName()獲取的類與自定義載入類得到的類不是同一個類。

根據五種初始化的條件,父類也會被初始化,但是,上邊的程式碼執行結果顯示,父類和介面都沒有被初始化,這又是怎麼回事呢?

系統提供了三種類載入器,分別是:啟動類載入器(Bootstrap ClassLoader),該載入器會將<JAVA_HOME>/lib目錄下能被虛擬機器識別的類載入到記憶體中。擴充套件類載入器(Extension ClassLoader),該載入器會將<JAVA_HOME>/lib/ext目錄下的類庫載入到記憶體。應用程式類載入器(Application ClassLoader),該載入器負責載入使用者路徑上所指定的類庫。

我們自定義的ClassLoader繼承自應用程式類載入器,當自定義類載入器找不到所加在的類時,會使用啟動類載入器進行載入,當啟動類載入器載入不到時,由擴充套件類載入,擴充套件類載入不到時有應用程式類載入。這也是為什麼上邊的程式碼能夠成功執行的原因。

三  位元組碼執行引擎

執行時棧幀結構

http://my.oschina.net/jiangmitiao/blog/470426  中講到虛擬機器棧是執行緒私有的,執行緒中會為執行的方法建立棧幀。

JAVA 虛擬機器類載入機制和位元組碼執行引擎

棧幀是虛擬機器棧的棧元素,棧幀儲存了區域性變數表,運算元棧,動態連線,返回地址等資訊。每一個方法的呼叫都對應著一個棧幀在虛擬機器棧中的入棧和出棧。

區域性變數表由方法引數,方法內定義的區域性變數組成,容量以變數槽(Slot)為最小單位。如果該方法不是static方法,則區域性變數表的第一個索引為該物件的引用,用this可以取到。

運算元棧最開始為空,由位元組碼指令往棧中存資料和取資料,方法的返回值也會存到上一個方法的運算元棧中。

動態連線含有一個指向常量池中該棧幀所屬方法的引用,持有該引用是為了進行動態分派。

方法返回地址存放的是呼叫該方法的pc計數器值,當方法正常返回時,就會把返回值傳遞到上層方法呼叫者。當方法中發生沒有可被捕獲的異常,也會返回,但是不會向上層傳遞返回值。

方法呼叫

java是一門物件導向的語言,它具有多型性。那麼虛擬機器又是如何知道執行時該呼叫哪一個方法?

靜態分派是在編譯期就決定了該呼叫哪一個方法而不是由虛擬機器來確定,方法過載就是典型的靜態分派。

動態分派是在虛擬機器執行階段才能決定呼叫哪一個方法,方法重寫就是典型的動態分派。

動態分派的實現:當呼叫一個物件的方法時,會將該物件的引用壓棧到運算元棧,然後位元組碼指令invokevirtual會去尋找該引用實際型別。如果在實際型別中找對應的方法,且訪問許可權足夠,則直接返回該方法引用,否則會依照繼承關係對父類進行查詢。實際上,如果子類沒有重寫父類方法,則子類方法的引用會直接指向父類方法。

基於棧的位元組碼執行引擎

不管是解釋型語言還是編譯型語言,機器都無法理解非二進位制語言。高階語言轉化成機器語言都遵循現代經典編譯原理。即執行前對程式原始碼進行詞法和語法分析,構建抽象語法樹。C語言等編譯型語言會由單獨的執行引擎做這些工作,而Java語言等解釋型語言語法抽象樹由jvm完成。jvm可以選擇通過直譯器來解釋位元組碼執行還是通過優化器生成機器程式碼來執行。

常用的兩套指令集架構分別是基於棧的指令集和基於暫存器的指令集。

基於棧的指令集更多的通過入棧出棧來實現計算功能,例如1+1

    iconst_1  ;將1入棧
    iconst_1  ;將1入棧
    iadd      ;將棧頂兩個元素取出相加並將結果入棧

基於暫存器的指令集更多的是使用暫存器來進行操作,例如1+1

mov eax,1 ;向eax中存1
 add eax,1 ;eax<-eax+1

總體來說,基於棧的指令集會慢一些,但是它與暫存器無關,更容易實現到處執行的目標。

總結

又到了該總結的時候了,類載入機制面試中很容易被問到,不幸的是,當時我並沒有看這方面的知識。

class類檔案結構的每一個部分都可以再深入下去,類檔案結構是採用結構體的方式儲存的,那麼怎麼知道集合的長度,各個屬性又是怎麼被標記的。

類載入機制中有且僅有的五種觸發初始化的情況。類載入器的分類。

棧幀的結構,以及方法呼叫。

java語言的方法呼叫分為靜態多分派,動態單分派。

相關文章