Java編譯(二)Java前端編譯:
Java源代碼編譯成Class文件的過程
在上篇文章《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 */ 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(JSR 269: Pluggable AnnotationProcessing API),取代 APT(JEP 117: Remove the Annotation-ProcessingTool),可以實現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的情況,如在類普通塊中直接拋出異常:
會發生:錯誤: 初始化程序必須能夠正常完成(error: initializer must be able to complete normally),如圖{ throw new RuntimeException(); }
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; //錯誤: 變量f_uinit未在默認構造器中初始化 public final int f = 2; public static final int sf_uinit; //錯誤: 變量sf_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