Java 原始碼編譯成 Class 檔案的過程分析

tjiyu的部落格發表於2016-12-26

在上篇文章《Java三種編譯方式:前端編譯 JIT編譯 AOT編譯》中瞭解到了它們各有什麼優點和缺點,以及前端編譯+JIT編譯方式的運作過程。

下面我們詳細瞭解Java前端編譯:Java原始碼編譯成Class檔案的過程;我們從官方JDK提供的前端編譯器javac入手,用javac編譯一些測試程式,除錯跟蹤javac原始碼,看看javac整個編譯過程是如何實現的。

1、javac編譯器

1-1、javac原始碼與除錯

javac編譯器是官方JDK中提供的前端編譯器,JDK/bin目錄下的javac只是一個與平臺相關的呼叫入口,具體實現在JDK/lib目錄下的tools.jar。此外,JDK6開始提供在執行時進行前端編譯,預設也是呼叫到javac,如圖:

javac是由Java語言編寫的,而HotSpot虛擬機器則是由C++語言編寫;標準JDK中並沒有提供javac的原始碼,而在OpenJDK中的提供;我們需要在Eclipse中除錯跟蹤javac原始碼,看整個編譯過程是如何實現的。

javac編譯器原始碼下載(JDK8):http://hg.openjdk.java.net/jdk8u/jdk8u-dev/langtools/archive/tip.tar.bz2

javac編譯器原始碼目錄:**\src\share\classes\com\sun\tools\javac

在Eclipse新建工程匯入後,可以看到javac原始碼的目錄結構如下:

javac編譯器程式入口:com.sun.tools.javac.Main類中的main()方法;

執行javac程式,先是解析命令列引數,由com.sun.tools.javac.main.Main.compile()方法處理,程式碼片段如下:

因為沒有給引數,可看到輸出的是javac用法,如下:

這就是平時我們用JDK/bin/javac的用法,更多javac選項用法請參考:http://docs.oracle.com/javase/8/docs/technotes/tools/unix/javac.html

除錯編譯檔案,需要右鍵工程 -> Debug As -> Debug Configurations ->切換到Arguments選項卡,在Program arguments中輸入我們要用javac編譯的Java程式檔案的路徑即可;然後就可以打斷點Debug執行除錯了,如圖:

1-2、javac編譯過程

JVM規範定義了Class檔案結構格式,但沒有定義如何從java程式檔案轉化為Class檔案,所以不同編譯器可以有不同實現。

從javac編譯器原始碼來看,其編譯過程可以分為3個子過程:

1、解析與填充符號表過程:解析主要包括詞法分析和語法分析兩個過程;

2、插入式註解處理器的註解處理過程;

3、語義分析與位元組碼的生成過程;

如圖所示(來自參考4):

javac編譯動作入口: com.sun.tools.javac.main.JavaCompiler類;

3個編譯過程邏輯集中在這個類的compile()和compile2()方法;

如圖所示:

1-3、javac中的訪問者模式

訪問者模式可以將資料結構和對資料結構的操作解耦,使得增加對資料結構的操作不需要修改資料結構,也不必修改原有的操作,而執行時再定義新的Visitor實現者就行了。

Javac經過第一步解析(詞法分析和語法分析),會生成用來一棵描述程式程式碼語法結構的抽象語法樹,每個節點都代表程式程式碼中的一個語法結構,包括:包、型別、修飾符、運算子、介面、返回值、甚至註釋等;而後的不同編譯階段都定義了不同的訪問者去處理該語法樹(節點)。

瞭解這些更容易理解javac的編譯過程實現,而後面分析過程中會再對訪問者模式的實現作相關說明。

2、解析與填充符號表

2-1、解析:詞法、語法分析

解析包括:詞法分析和語法分析兩個過程;

2-1-1、詞法分析

1、概念解理

詞法分析是將原始碼的字元流轉變為標記(Token)集合;

標記:

標記是編譯過程的最小元素;

包括關鍵字、變數名、字面量、運算子(甚至一個”.”)等;

2、原始碼分析:                                

由com.sun.tools.javac.parser.Scanner類實現對外部提供服務;

由com.sun.tools.javac.parser.JavaTokenizer類實現具體的Token分析動作(JavaTokenizer.readToken()方法);

Scanner.nextToken()呼叫JavaTokenizer.readToken()方法讀取下一個Token;

返回com.sun.tools.javac.parser.Tokens.Token類例項表示的一個Token;

Scanner.nextToken()方法如下:

注意,下面語法分析時才會不斷呼叫Scanner.nextToken()讀取一個個Token進來解析。

2-1-2、語法分析

1、概念解理

語法分析是根據Token序列構造抽象語法樹的過程;

抽象語法樹(Abstract Syntax Tree,AST):

是一種用來描述程式程式碼語法結構的樹形表示方式;

每個節點都代表程式程式碼中的一個語法結構;

語法結構(Construct)包括:包、型別、修飾符、運算子、介面、返回值、甚至註釋等;

2、原始碼分析:

由com.sun.tools.javac.parser.JavacParser類完成整個過程,該類實現com.sun.tools.javac.parser.Parser介面;

一個類檔案解析產生的抽象語法樹的所有內容儲存在JCCompilationUnit類例項裡,JCCompilationUnit類是由com.sun.tools.javac.tree.JCTree類擴充套件;

JCTree是個抽象類,實現了Tree介面,Tree介面裡有一個”<R,D> R accept(TreeVisitor<R,D> visitor, D data)”方法用來接收訪問者,所以Tree介面是訪問者模式中的抽象節點元素;

JCTree類中有一個Visitor內部類,同時也是一個抽象類,作為訪問者模式中的抽象訪問者;

一個JCTree類例項相當於抽象語法樹的一個節點,它會擴充套件許多型別,對應不同語法結構型別的樹節點,如JCStatement,JCClassDecl,JCMethodDecl,JCBlock等等,這些類是訪問者模式中的具體節點元素;

JCTree擴充套件的JCMethodDecl方法型別節點結構如下:

程式碼執行的解析過程,如下:

1)、由JavaCompiler.compile()方法呼叫JavaCompiler.parseFiles()方法完成引數輸入的所有檔案的編譯;

2)、JavaCompiler.parseFiles()方法中又呼叫本類中的parse()方法對其中一個檔案進行編譯;

該方法中生成JavacParser類例項,然後呼叫該例項的parseCompilationUnit()方法開始進行整個檔案的解析(包括”package”包名),如下:

Parser parser = parserFactory.newParser(content, keepComments(), genEndPos, lineDebugInfo);  
tree = parser.parseCompilationUnit();

返回的tree是JCCompilationUnit型別例項,儲存了一個類檔案解析產生的抽象語法樹的所有內容,也可以說是抽象語法樹的根節點;

3)、JavacParser.parseCompilationUnit()方法中呼叫JavacParser.typeDeclaration()進行檔案中所有型別定義的解析;

JavacParser.typeDeclaration()又呼叫JavacParser.classOrInterfaceOrEnumDeclaration()進行類或介面的解析;

如果是類又呼叫classDeclaration()對該類進行解析….

JCTree def = typeDeclaration(mods, docComment);

返回一個JCTree類例項表示檔案中所有型別定義定義的語法樹(不包括”package”包名);

這期間會不斷呼叫Scanner.nextToken()讀取一個個Token進來解析;        

3、編譯測試:

下面我們用javac編譯JavacTest.java檔案來跟蹤整個解析過程,測試檔案程式碼如下:

package com.jvmtest;  

public class JavacTest {  
   private int i;  

   public int getI() {  
       return i;  
   }  

   public void setI(int i) {  
       this.i = i;  
   }      

}

對於解析JavacTest.java檔案生成的抽象語法樹,由返回的JCCompilationUnit類例項表示,如下圖所示:

最外層節點為”com.jvmtest”包名的定義,同時它也是語法樹的根節點;

再裡一層是”public class JavacTest”類的定義;

再裡面可以看到一個欄位變數”i”的結構節點,以及兩個方法”getI”和”setI”節點;

4、類例項建構函式重名為<init>():

先在再上面的測試程式中加入類例項建構函式:

Public JavacTest() {  

 }

需要注意的是,在classOrInterfaceBodyDeclaration()解析類時,如果遇到新增的類建構函式,會重名為<init>(),如下:

如測試程式中加入類建構函式,可以看到被重新命名<init>(),但在生成的樹結構上名稱還是表現為”JavacTest”,如下

經過上面解析,後續所有操作都建立在抽象語法樹之上,下面不會再對原始碼檔案操作;

2-2、填充符號表

1、概念解理

符號表(Symbol Table)是由一組符號地址和符號資訊構成的表格,可以想象成雜湊表中K-V值的形式;

符號表登記的資訊在編譯的不同階段都要用到,如:

1)、用於語義檢查和產生中間程式碼;

2)、在目的碼生成階段,符號表是對符號名進行地址分配的依據;

2、原始碼分析:

根據上一步生成的抽象語法樹列表,由JavaCompiler.enterTrees()方法完成填充符號表;

由com.sun.tools.javac.comp.Enter類實現填充符號表動作,Enter類繼承JCTree.Visitor內部抽象類,重寫了一些visit**()方法來處理抽象語法樹,作為訪問者模式中的具體訪問者;

符號由com.sun.tools.javac.code.Symbol抽象類表示, 實現了Element介面,Element介面裡有一個accept()方法用來接收訪問者,所以Element介面是訪問者模式中的抽象節點元素;

Symbol類擴充套件成多種型別的符號,如ClassSymbol表示類的符號、MethodSymbol表示方法的符號等等,這些類是訪問者模式中的具體節點元素;

Symbol類和MethodSymbol類定義如下:

public abstract class Symbol extends AnnoConstruct implements Element {  

   /** The kind of this symbol. 
    * @see Kinds 
   <span style="font-family:微軟雅黑;"> </span>*/  
    public int kind;  

    /** The flags of this symbol. 
    */  
    public long flags_field;  

    /** An accessor method for the flags of this symbol. 
    * Flags of class symbols should be accessed through the accessor 
    * method to make sure that the class symbol is loaded. 
    */  
    public long flags() { return flags_field; }  

   /** The name of this symbol in Utf8 representation. 
    */  
    public Name name;  

    /** The type of this symbol. 
    */  
    public Type type;  

    /** The owner of this symbol. 

    */  
    public Symbol owner;  

    /** The completer of this symbol. 

    */  
    public Completer completer;  

   /** A cache for the type erasure of this symbol. 
    */  
    public Type erasure_field;  

    // <editor-fold defaultstate="collapsed" desc="annotations">  

    /** The attributes of this symbol are contained in this 
    * SymbolMetadata. The SymbolMetadata instance is NOT immutable. 
    */  
    protected SymbolMetadata metadata;  

       ......  

    }
/** A class for method symbols. 
*/  

public static class MethodSymbol extends Symbol implements ExecutableElement {  

    /** The code of the method. */  
    public Code code = null;  

    /** The extra (synthetic/mandated) parameters of the method. */  
    public List<VarSymbol> extraParams = List.nil();  

    /** The captured local variables in an anonymous class */  
    public List<VarSymbol> capturedLocals = List.nil();  

    /** The parameters of the method. */  
    public List<VarSymbol> params = null;  

    /** The names of the parameters */  
    public List<Name> savedParameterNames;  

    /** For an attribute field accessor, its default value if any. 
    * The value is null if none appeared in the method 
    * declaration. 
    */  
    public Attribute defaultValue = null;  

        ......  

    }

從上面可以看到它們包含了哪些資訊;

程式碼執行的填充過程,如下:

1)、JavaCompiler.enterTrees()方法呼叫Enter.main()方法;

根據上一步生成的抽象語法樹列表完成填充符號表,返回填充了類中所有符號的抽象語法樹列表;

2)、Enter.main()方法呼叫中本類的complete()方法;

complete()方法先呼叫Enter.classEnter()方法完成填充包符號、類符號以及匯入資訊等;

3)、接著complete()方法還會不斷呼叫前面生成的每個類的類符號例項的ClassSymbol.complete()方法;

ClassSymbol.complete()方法會呼叫到MemberEnter.complete(),以完成整個類的填充符號表;

4)、MemberEnter.complete()中會新增類的預設建構函式(如果沒有任何的);

還會呼叫 MemberEnter.finish()方法完成對類中欄位和方法符號的填充;

等等(其實先處理註解資訊)…

注意,EnterTrees()方法最終完成返回一個待處理列表(”todo” list),其實該列表還是抽象語法樹列表,符號只是填充到上一步生成的抽象語法樹列表中;可以從上面語法分析給出的JCMethodDecl類中看到有一個MethodSymbol類的成員變數;

3、編譯測試

還用上面的JavacTest.java檔案測試,其中getI()方法的符號如下(顯示符號名稱):

測試JavacTest.java檔案填充符號表的前後,抽象語法樹列表變化(紅色)如下:

4、計算方法的特徵簽名

其實MethodSymbol方法符號中的MethodType型別的type成員就是其特徵簽名;

在.MemberEnter.visitMethodDef(JCMethodDecl tree)中填充方法符號的時候計算特徵簽名,如下:

public void visitMethodDef(JCMethodDecl tree) {  
        ......  

        MethodSymbol m = new MethodSymbol(0, tree.name, null, enclScope.owner);  
        ......  
        // Compute the method type  
        m.type = signature(m, tree.typarams, tree.params,  
                                  tree.restype, tree.recvparam,  
                                  tree.thrown,  
                                  localEnv);  
         ......  
 }

MethodType如下:

public static class MethodType extends Type implements ExecutableType {  

    public List<Type> argtypes;  
    public Type restype;  
    public List<Type> thrown;  

    /** The type annotations on the method receiver. 
    */  
    public Type recvtype;  

    public MethodType(List<Type> argtypes,  
       Type restype,  
       List<Type> thrown,  
       TypeSymbol methodClass) {  

         super(methodClass);  

         this.argtypes = argtypes;  
         this.restype = restype;  
         this.thrown = thrown;  

    }  

    ......

可以看到特徵簽名包含了返回值型別,其實方法特徵簽名在Java語言層面和JVM層面是不同的:

Java語言層面特徵簽名:

方法名、引數型別和引數順序;

JVM層面特徵簽名:

方法名、引數型別、引數順序和返回值型別;

這個在後面文章介紹Class檔案格式再詳細說明;

5、新增預設類例項建構函式、”this”類變數符號、”super”父類變數

這個階段,編譯器自動新增預設類例項建構函式、”this”類變數符號、”super”父類變數符號:

(a)、如果類中沒有定義任何例項建構函式,編譯器會自動新增預設的類例項建構函式;

在完成一個類的填充符號時呼叫:

MemberEnter.complete(Symbol sym){  
      ......  
      // Add default constructor if needed.  
      if ((c.flags() & INTERFACE) == 0 &&  
      !TreeInfo.hasConstructors(tree.defs)) {  
           ......  
           if (addConstructor) {  

              MethodSymbol basedConstructor = nc != null ?  
                 (MethodSymbol)nc.constructor : null;  
                      JCTree constrDef = DefaultConstructor(make.at(tree.pos),   
                                                           c, basedConstructor, typarams,  
                                                           argtypes, thrown, ctorFlags, based);  

                      tree.defs = tree.defs.prepend(constrDef);  
            }  
             ......  
     }  
     ......  
}

測試JavacTest.java檔案新增的例項建構函式如下:

可以看到新增的類例項構造名稱為<init>(),雖然樹結構上名稱還是表現為”JavacTest”;

還有新增的時候會判斷當前類的型別如果不是Object型別,都會在建構函式裡新增”super();”,表示呼叫父類的建構函式,如下:

(b)、新增”this”類變數

在類例項作用域新增”this”符號,表示當前類例項,如下:

(c)、”super”父類變數符號

接著,在類例項作用域新增”super”符號,表示類父,如下:

3、插入式註解處理器的註解處理過程

JDK1.5後,Java語言提供了對註解(Annotation)的支援,註解和Java程式碼一樣,都是在執行期間發揮作用;

JDK1.6中提供一組外掛式註解處理器的標準API,可以實現API自定義註解處理器,干涉編譯器的行為;

註解處理器可以看作編譯器的外掛,在編譯期間對註解進行處理,可以對語法樹進行讀取、修改、新增任意元素;但如果有註解處理器修改了語法樹,編譯器將返回解析及填充符號表的過程,重新處理,直到沒有註解處理器修改為止,每一次重新處理迴圈稱為一個Round。

如hibernate Validator Annotation Process:用於校驗Hibernate標籤。

1、原始碼分析

註解處理器的初始化過程在JavaCompiler.initProcessAnnotations()方法中完成;

執行過程則是JavaCompiler.processAnnotations()方法;

如果有多個註解處理器,在JavacProcessingEnvironment.doProcessing()繼續處理;

2、註解處理器實現與執行

程式碼實現:繼承抽象類javax.annotation.processing.AbstractProcess,並覆蓋abstract方法:”process()”;

執行/測試:通過javac -processor引數附帶編譯時的註解處理器;

這裡我們沒有實現註解處理器,執行javac編譯JavacTest.java不會處理語法樹;

4、語義分析與位元組碼生成

上面我們獲得了填充了符號表的抽象語法樹列表;

它能表示程式的結構,但無法保證程式的符合邏輯。

4-1、語義分析

主要任務是對結構上正確的源程式進行上下文有關性質的審查(如型別審查);

語義分析過程分為標註檢查、資料及控制流分析兩個步驟;

4-1-1、標註檢查

1、概念解理

標註檢查步驟檢查的內容包括變數使用前是否已被宣告、變數與賦值的資料型別是否能匹配等;

還有比較重要的動作稱為常量摺疊;

如前面測試程式”int i;”改為”int i=1+2;”,會被摺疊成字面量”3″,與”int i=3″一樣,如圖:

2、原始碼分析

主要由com.sun.tools.javac.comp.Attr類和com.sun.tools.javac.comp.Check類完成,呼叫關係如下圖:

由JavaCompiler.attribute()入口分析整個類的語法樹的標註;

到Attr.attribClassBody()分析類的主體部分,如進行所有定義的檢查:

comp.Check類的例項在Attr.attribClassBody()分析中進行定義、型別等檢查;

如”boolean k = 1″,最終是通過型別檢查賦值資料”1″的型別”int”不是接收者”k”的型別”Boolean”的父類來確定錯誤,如下:

3、自動新增super():

方法檢查時,如果發現(自己定義的)類例項建構函式沒有顯式呼叫super()或this(),會新增super()的父類建構函式呼叫,如下:

但是,前面說過如果沒有自定定義任何建構函式,前面填充符號表時,就已經新增含有super()的預設建構函式了;

4、標註檢查結果            

標註檢查中已經使用Env<AttrContext>類例項作為類編譯資訊的儲存形式,它包含了一些訪問上下文環境。

還是前面的測試程式,標註檢查前後變化(紅色)如下:

4-1-2、資料及控制分析

1、概念解理    

資料及控制分析是對程式上下方邏輯更進一步的驗證;

如檢查變數的初始化、方法每個執行分支是否都有返回值、是否所有的異常都被正確處理等;

注意這階段並不會對變數賦值;

這個時期與類載入時的資料及控制分析的目的一致,但校驗範圍不同;

如final修飾的區域性變數:

final修飾的區域性變數是在這個編譯階段處理的;

有沒有final修飾符,編譯出來的Class檔案都一樣,在常量池沒有CONSTANT_Fiedref_info稱號引用;

即在執行期沒有影響,引數不變性由編譯器在編譯期保障;

2、原始碼分析

主要由 com.sun.tools.javac.comp.Flow類實現;

呼叫關係如下:

主要在其analyzeTree()方法中完成分析,如下:

 public void analyzeTree(Env<AttrContext> env, TreeMaker make) {  

         //1、活性分析:檢查每個語句是否可訪問;  

        new AliveAnalyzer().analyzeTree(env, make);  

        //2、(i)、賦值分析:檢查確保每個變數在使用前已被初始化;  
        //   (ii)、未賦值分析:檢查確保final修飾變數的不變性(不會被第二次賦值);  
        //    還用於標記"effectively-final"區域性變數/引數;  
        //    使用活性分析的結果;  

        new AssignAnalyzer().analyzeTree(env);  

        //3、異常分析:檢查確保每個異常被丟擲、宣告或捕獲;  
        //    需要使用活性分析設定的一些資訊;  

        new FlowAnalyzer().analyzeTree(env, make);  

        //4、"effectively-final"分析:這檢查每個來自lambda body/local內部類的區域性變數引用是"final or effectively";  
        //    由於effectively final變數在DA/DU期間被標記,所以該步驟必須在AssignAnalyzer之後執行;  

        new CaptureAnalyzer().analyzeTree(env, make);  

}

1)、活性分析

new AliveAnalyzer().analyzeTree(env, make);

檢查每個語句是否可訪問;

它裡面有一個方法makeDead(),它的呼叫關係如下:

可以看到訪問到return/break/continue以及thorw關鍵字,就會呼叫標記後面的語句不能再訪問;

如果還有就會發現編譯錯誤:

(A)、如程式中,方法return後,還有邏輯,就會發生錯誤,如下:

public void setI(int i) {          
     return ;  
     this.i = i;  
}

 會發生:錯誤:無法訪問的語句(unreachable stmt),如圖:

(B)、還有throw的情況,如在類普通塊中直接丟擲異常:

{  
      throw new RuntimeException();          
}

會發生:錯誤: 初始化程式必須能夠正常完成(error: initializer must be able to complete normally),如圖

2)、賦值分析

new AssignAnalyzer().analyzeTree(env);

檢查確保每個變數在使用前已被初始化;

檢查確保final修飾變數的不變性(不會被第二次賦值);

注意,如果例項成員方法中為final成員變數賦值,會在標註檢查階段分析出錯誤;

這裡的檢查主要是物件final修飾的變數,下面我們用另一個程式編譯測試,如下:

public class JavacTest {  

    public static int s_uinit;  
    public static int s = 1;                                

    public final int f_uinit; //錯誤: 變數sf_uinit未在預設構造器中初始化  
    public final int f = 2;  

    public static final int sf_uinit; //錯誤: 變數f_uinit未在預設構造器中初始化  
    public static final int sf = 3;  

    private int i_uinit;  
    private int i = 4;  

    public void test(final int methodParam_f) {      

        final int method_f_uinit;  
        final int method_f = methodParam_f;  

        this.i = method_f_uinit;   //錯誤: 可能尚未初始化變數method_f_uinit                                    
        this.i = method_f;  

        method_f_uinit = 1;      
        method_f_uinit = 2;    //錯誤: 可能已分配變數method_f_uinit  

        //f_uinit = 12;   //錯誤(屬於標註檢查錯誤)  
    }                  
}

這個程式編譯會出現四個錯誤,我們看下是怎麼檢查的:

AssignAnalyzer裡面有一個trackable()方法,說明這裡應該關注什麼樣的欄位/變數符號的初始化;

從它實現中可以看出檢查主要是物件final修飾的欄位/變數,如下:

還有一個newVar()方法,當然發現應該關注檢查的欄位/變數後,newVar()方法會把這個符號記錄下來;

它在三個地方呼叫,在visitClassDef()裡檢查static類欄位和非static類例項欄位時,以及在visitVarDef()檢查方法中的變數及引數,呼叫如下:

A)、檢查static類欄位和非static類例項欄位

從上圖可以看到,先是檢查static類欄位和非static類例項欄位,把關注的未進行初始化的final欄位記錄下來;

而後再檢查方法,先是檢查類例項構造方法;

這時會把前面記錄的欄位,通過checkInit()再次檢查確認;

如果的確定是未進行初始化的final欄位,報告相關錯誤,如下:

錯誤: 變數 sf_uinit 未在預設構造器中初始化(var not initialized in default constructor)

錯誤: 變數 f_uinit 未在預設構造器中初始化

(B)、檢查方法的傳入引數

接著還是visitMethodDef()檢查類中的方法(訪問者模式);

先檢查方法引數,如下:

雖然scan()中檢查並記錄了測試程式test()方法methodParam_f引數,但是下面立刻呼叫initParam()刪除了記錄;

所以方法的final引數未初始化並不影響下面的使用;

可以認為執行時傳入的final引數都是賦值初始化了的;

(C)、檢查方法中的變數定義

接著檢查方法體中定義的變數;

其中method_f_uinit變數未初始化,被記錄下來;

而method_f變數雖然開始被記錄下來,

但它初始化為methodParam_f引數值,所以立即呼叫letInit()刪除了相關記錄,如下圖:

所以下面它也可以被使用(this.i = method_f);

(D)、檢查方法執行中的變數使用

注意,上面檢查方法中的引數和變數,只是記錄下來定義時未初始化final變數,這裡才是檢查使用前已被初始化;

可以看到method_f_uinit變數在上面被記錄下來,使用時作為”Ident”檢查;

在visitIdent()中呼叫checkInit()確定其未初始化,然後列印錯誤,如下:

錯誤: 可能尚未初始化變數method_f_uinit(var might not have been initialized);

而method_f變數初始化為methodParam_f,未被記錄,所以checkInit()檢查通過,正常使用;

(E)、檢查final修飾變數不會被二次賦值

注意,如果例項成員方法中為final成員變數賦值(方法中f_uinit = 12),會在標註檢查階段分析出錯誤;

但在類塊{}中為未初始化的final成員變數賦值(相當於在建構函式賦值),也會發生檢查二次賦值的情況;

方法中兩次為method_f_uinit變數賦值;

檢查賦值操作是visitAssign()方法,裡面會為左值method_f_uinit變數呼叫letInit();

第一次因為定義時沒有初始化,所以letInit()中呼叫uninit()把前面定義時未初始化的記錄刪除;

第二次因為沒有了記錄,所以letInit()中列印出錯誤,如圖:

錯誤:可能已分配變數method_f_uinit(var might already be assigned);

正如前面說的:

final修飾的區域性變數是在這個編譯階段處理的;

有沒有final修飾符,編譯出來的Class檔案都一樣,在常量池沒有CONSTANT_Fiedref_info稱號引用;

即在執行期沒有影響,引數不變性由編譯器在編譯期保障;

4-2、解語法糖

1、概念解理    

語法糖(Syntactic Sugar)也稱糧衣語法;

物件語言功能沒有影響,只是簡化程式,提高效率,增可讀性,減少出錯;

但使得程式設計師難以看清程式的執行過程;

Java最常用的有:

泛型、變長引數、自動裝箱/拆箱、遍歷迴圈、內部類、斷言等;

JVM不支援這些語法;

在編譯階段還原回簡單的基礎語法結構,稱為解語法糖

2、原始碼分析                

入口呼叫com.sun.tools.javac.main.JavaCompiler.desugar()完成;

主要由com.sun.tools.javac.comp.TransTypes類和com.sun.tools.javac.comp.Lower類實現;

3、泛型與型別擦除

拿泛型來說,泛型是JDK1.5的新增特性;

本質是引數化型別(Parametersized Type)的應用;

即所操作的資料型別被指定為一個引數;

可以用在類、介面和方法的建立中,分別稱為泛型類、泛型介面和泛型方法;

(A)、Java泛型與C#泛型

C#的泛型在程式、編譯後、執行期都是存在的;

對於List<int>和List<String>是兩種不現的型別,在執行期有自己的虛方法表和型別資料;

這種實現方法稱為型別膨脹,基於這種方法實現的泛型稱為真實泛型

Java語言泛型在編譯後的位元組碼檔案中,就被替換為原來的原生型別(Raw Type);

並在相應地方插入強制轉型程式碼;

對於ArrayList<int>和ArrayList<String>,在執行期是同一種型別;

這種實現方法稱為型別擦除,基於這種方法實現的泛型稱為偽泛型

(B)、執行時識別(反射)泛型引數型別

對於泛型型別擦除後,需要在執行時識別(反射)泛型引數型別的問題:

JVM規範引入了Signature、LocalVariableTypeTable等Class屬性;

Signature儲存一個方法在位元組碼層面的特徵簽名,儲存了引數化型別的資訊;

這也是能通過反射手段取得引數化型別的根本依據;

相關Class屬性會在後面文章介紹Class檔案格式時再說明;

(C)、編譯測試

測試程式JvmTest10_2.java,如下:

package com.jvmtest;  

import java.util.HashMap;  
import java.util.Map;  

public class JvmTest10_2 {  

    public static void main(String[] args) {  
        Map<String, String> map = new HashMap<String, String>();  
        map.put("hello", "java");  
        System.out.println(map.get("hello"));  
    }      
}

泛型擦除呼叫關係及關鍵程式碼,如下:

測試程式中泛型經過編譯型別被擦除,如下:

4-3、位元組碼生成

1、概念理解

把前面生成的語法樹、符號表等資訊轉化成位元組碼,然後寫到磁碟Class檔案中;

2、原始碼分析

由com.sun.tools.javac.jvm.Gen類實現新增程式碼和轉換位元組碼;

入口呼叫com.sun.tools.javac.jvm.Gen.genClass(),呼叫關係如下:

完成轉換後,由com.sun.tools.javac.main.JavaCompiler的writer()方法寫到磁碟Class檔案,如下:

3、類構造器<clinit>()與例項構造器<init>()

另外,還進行了少量程式碼新增,如類構造器<clinit>()到語法樹中;

注意,通過前面的分析可以知道,對於例項構造器<init>(),如果程式程式碼中定義有建構函式,它在解析的語法分析階段被重新命名為<init>();如果沒有定義建構函式,則例項構造器<init>()是在填充符號表時新增的。

並把需要初始化的變數以及需要執行的語句塊新增到相應的構造器中;

Gen.genClass()中會呼叫Gen.normalizeDefs()方法,進行新增例項構造器<init>()和類構造器<clinit>()到語法樹;

用下面的程式測試Parent.java和Child.java,看新增了什麼內容到兩個構造器中,Parent.java如下:

package com.jvmtest;  

public class Parent {  

    static int i = 1;                        

    {  
        System.out.println("父類例項塊1:" + i++);  
    }  

    static {  
        System.out.println("父類靜態塊1:" + i++);  
    }  

    private String mStr1="父類例項變數1:" + i++;  
    private static String mStaticStr1="父類靜態類變數1:" + i++;  

    {  
        System.out.println("父類例項塊2:" + i++);  
    }  

    static {  
        System.out.println("父類靜態塊2:" + i++);  
    }  

    private String mStr2="父類例項變數2:" + i++;  
    private static String mStaticStr2="父類靜態類變數2:" + i++;  

    public Parent() {  
        System.out.println("父類構造器:" + i++);  
    }  

    public void print1() {  
        String str="父類區域性變數:" + i++;  

        System.out.println("父類方法print1():\n " +  
                mStaticStr1 + "\n " + mStaticStr2 + "\n " +  
                mStr1 + "\n " + mStr2 + "\n " +  
                str);  
    }  
}

Child.java如下:

package com.jvmtest;  

public class Child extends Parent{  

    {  
        System.out.println("子類例項塊1:" + i++);  
    }  

    static {  
        System.out.println("子類靜態塊1:" + i++);  
    }  

    private String mStr1="子類例項變數1:" + i++;  
    private static String mStaticStr1="子類靜態類變數1:" + i++;  

    {  
        System.out.println("子類例項塊2:" + i++);  
    }  

    static {  
        System.out.println("子類靜態塊2:" + i++);  
    }  

    private String mStr2="子類例項變數2:" + i++;  
    private static String mStaticStr2="子類靜態類變數2:" + i++;  

    public Child() {  
        System.out.println("子類構造器:" + i++);  
    }  

    public void print2() {  

        String str="子類區域性變數:" + i++;  

        System.out.println("子類方法print2():\n " +  
                 mStaticStr1 + "\n " + mStaticStr2 + "\n " +  
                 mStr1 + "\n " + mStr2 + "\n " +  
                    str);  
    }  

    public static void main(String[] args){  
         Child child = new Child();  
         child.print1();  
         child.print2();                              
    }

1)、它先把一個類的定義宣告符號分為三類儲存

A、initCode:儲存需要初始化執行的例項變數和塊(非static);

B、clinitCode:儲存需要初始化執行的類變數和塊(static);

C、methodDefs:儲存方法定義符號;

程式程式碼分類後的如下:

2)、把initCode中的定義插入到例項構造器<init>()中

注意,對於例項構造器<init>(),如果程式程式碼中定義有建構函式,它在解析的語法分析階段被重新命名為<init>();

如果沒有定義建構函式,則例項構造器<init>()是作為預設建構函式,是在填充符號表時新增的;

另外<init>()中的super()呼叫父類<init>(),

在語義分析的標註檢查在方法檢查時,如果發現自己定義的類建構函式沒有顯式呼叫super()或this(),會新增super()的父類建構函式呼叫;

如果沒有自定定義任何建構函式,在前面填充符號表時新增的預設建構函式就已經含有super()了;

initCode插入<init>()原有程式碼的前面;

新增後的<init>()如下:

3)、把clinitCode中的定義插入到類構造器<clinit>()中

類構造器<clinit>()是在這時候建立的;

然後clinitCode插入到<clinit>(),再把<clinit>()放到方法定義methodDefs的後面,如下:

可以看到,<clinit>()並不呼叫父類的<clinit>(),這是由JVM保證的其執行;

我們執行上面的程式,可以看到輸出(後面的數字表明自執行順序):

測試表明執行順序如下:

先執行類構造器<clinit>():

父類靜態成員變數初始化、靜態語句塊(static{})執行;

子類靜態成員變數初始化、靜態語句塊(static{})執行;

靜態成員變數與靜態語句塊不區分,按照在程式碼中的位置順序執行;

不呼叫父類的類構造器,由JVM保證其執行;

而後執行例項構造器<init>():

父類例項成員變數初始化、例項語句塊({})執行;

父類例項構造器呼叫;

例項成員變數初始化、例項語句塊({})執行;

例項成員變數、例項語句塊不區分,按照在程式碼中的位置順序執行;

<init>()無論如何(自定義或編譯器新增)都有父類<init>()(super())呼叫;

而由於initCode插入<init>()原有程式碼的前面,所以例項成員變數初始化、例項語句塊({})執行輸出要先於構造器原來的程式碼執行輸出;

即按照先父類,後子類;先靜態、後例項的原則;

另外,<clinit>()是在Class檔案被類載入器載入的時候(初始化階段)執行,並且只執行一次(加鎖 );而<init>()在每次例項化物件時都會執行。

到這裡,我們大體瞭解javac把Java原始碼編譯成Class檔案的過程,可以用JDK提供的javap工具檢視反編譯後的檔案,如檢視JavacTest.class檔案:”javap -verbose JavacTest > JavacTest.txt”輸出到檔案、

後面我們將分別去了解: 前端編譯生成的Class檔案結構、以及JIT編譯–在執行時把Class檔案位元組碼編譯成本地機器碼的過程……

【參考資料】

1、javac原始碼

2、《編譯原理》第二版

3、《深入分析Java Web技術內幕》修訂版 第4章

4、《深入理解Java虛擬機器:JVM高階特性與最佳實踐》第二版 第10章

5、《The Java Virtual Machine Specification》Java SE 8 Edition:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

6、實時Java,第2部分: 比較編譯技術–本地 Java 程式碼的靜態編譯和動態編譯中的問題:www.ibm.com/developerworks/cn/java/j-rtj2/

7、很多文章都提到JVM對class檔案的編譯,那麼編譯後的檔案是在記憶體裡還是在哪?怎麼檢視?:https://www.zhihu.com/question/52487484/answer/130785455

相關文章