JVM(一)一文读懂Java编译全过程
一文讀懂Java編譯全過程
java代碼首先要通過前端編譯器編譯成.class字節碼文件,然后再按一定的規則加載到JVM(java 虛擬機)內運行,有三種運行方式,解釋模式(javac)、編譯模式(C1 JIT、C2 JIT)、混合模式(javac+(C1 OR C2))。解釋模式下,一邊執行字節碼一邊解釋執行;編譯模式下,字節碼編譯為機器碼后執行;混合模式下,正常情況下使用解釋執行,但是針對經常執行的代碼,會采用JIT技術進行編譯執行。無論是server運行模式下還是client運行模式下,都有可能采用解釋+(C1 OR C2 )執行。但本文的重點不在執行,而是編譯,包括前端編譯器、C1 JIT、C2 JIT。
如:一個是client 虛擬機模式,一個是server虛擬機模式,都是混合模式執行。
語言處理器種類
Java編譯過程
Java文件編譯過程包括兩個階段,第一階段是在編譯階段編譯成Java字節碼的過程,有些書籍中叫前端編譯器,如Oracle的javac編譯器;第二階段是在運行時,通過JVM的編譯優化組件,對代碼中的部分代碼編譯成本地代碼,即JIT編譯,如HotSpot中的C1、C2編譯器( Thus the threads used by client JIT compiler are called c1 compiler threads. Threads used by the server JIT compiler are called c2 compiler threads.)。JVM整個編譯過如下圖所示。
其中,編譯狀態有如下9種。
//編譯狀態 public enum CompileState {INIT(0),//初始化PARSE(1),//解析ENTER(2),//處理符號表PROCESS(3),//核心處理ATTR(4),//符號解析FLOW(5),//流分析TRANSTYPES(6),//解泛型為非泛型等類型轉換UNLAMBDA(7),//解LAMBDA表達式LOWER(8),//解語法糖GENERATE(9);//生成字節碼}下面是JIT編譯器和C1(C2)編譯器編譯流程。
Javac前端編譯器
當我們在控制臺執行javac命令時,找到javac對應的環境變量的可執行文件,通過JNI方式調用com.sun.tools.javac.Main.java中的main方法進入。也就是說Javac編譯工作是由Java代碼完成的。像javap,javah等命令也都是通過Java代碼完成的。
/*** launcher的入口.* Note: 該方法調用了System.exit.* @param args 命令行參數*/public static void main(String[] args) throws Exception {System.exit(compile(args));}//此代碼段在Main#compile方法中,用于讀取Java文件對象用于編譯。if (!files.isEmpty()) {// add filenames to fileObjectscomp = JavaCompiler.instance(context);List<JavaFileObject> otherFiles = List.nil();JavacFileManager dfm = (JavacFileManager)fileManager;for (JavaFileObject fo : dfm.getJavaFileObjectsFromFiles(files))otherFiles = otherFiles.prepend(fo);for (JavaFileObject fo : otherFiles)fileObjects = fileObjects.prepend(fo);}//調用JavaCompiler#compile方法comp.compile(fileObjects,//要編譯的文件對象classnames.toList(),//注解處理的類名processors);//用戶提供的注解處理器最終調用JavaCompiler.compile()方法進行編譯處理。如果自行編譯,可以調用java中提供的工具類ToolProvider.getSystemJavaCompiler() 自行進行編譯。如下是JavaCompiler.compiler()方法。
/*** 主方法:要編譯的文件列表,返回所有編譯的類* @param sourceFileObjects 要編譯的文件對象* @param classnames 為類中注解處理的類名* @param processors 用戶提供的注解處理器,null意味著沒有處理器提供。*/public void compile(List<JavaFileObject> sourceFileObjects,List<String> classnames,Iterable<? extends Processor> processors){if (processors != null && processors.iterator().hasNext())explicitAnnotationProcessingRequested = true;// 由于JavaCompiler只能使用一次,如果以前使用過,則拋出異常if (hasBeenUsed)throw new AssertionError("attempt to reuse JavaCompiler");hasBeenUsed = true;// forcibly set the equivalent of -Xlint:-options, so that no further// warnings about command line options are generated from this point onoptions.put(XLINT_CUSTOM.text + "-" + LintCategory.OPTIONS.option, "true");options.remove(XLINT_CUSTOM.text + LintCategory.OPTIONS.option);start_msec = now();try {//檢查是否要處理注解initProcessAnnotations(processors);// (1)這些方法必須是鏈式調用以避免內存泄漏delegateCompiler =processAnnotations(enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects))),classnames);// (2)分析和生成字節碼delegateCompiler.compile2();delegateCompiler.close();elapsed_msec = delegateCompiler.elapsed_msec;} catch (Abort ex) {if (devVerbose)ex.printStackTrace(System.err);} finally {if (procEnvImpl != null)procEnvImpl.close();}}從上面的代碼可知,編譯真正處理的代碼在(1)和(2)處。對代碼分析,編譯處理包括以下三個部分。分別為解析與填充符號表、注解處理、分析和生成字節碼三個大階段。
解析與填充符號表
解析與填充符號表,對應圖一的詞法分析、語法分析、抽象語法樹、填充符合表幾個細節處理。在解釋語法樹之前,我們首先要說下什么是語法樹,語法樹在很多語言中都有采用,如java、sql源碼閱讀中都用到了語法樹的概念。如下的英語句子的語法樹。
根據上面源碼中的(1)注解中的代碼,解析與填充符號表包括以下幾個步驟。
delegateCompiler =processAnnotations(enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects))),classnames);-
在第一個階段,所有的類符號都進入到Enter的范圍之內,樹中其他類的成員變量都嚴格降序排列。類符號被賦予一個MemberEnter對象作為"完成者"。除此之外,如果任何package-info.java文件被找到,并且包含包注解。樹節點的頂層將會為該文件添加到“代辦”列表。
-
將符號輸入到符號表。com.sun.tools.javac.comp.Enter,每個編譯單元的抽象語法樹的頂局節點都先被放到待處理列表中,逐個處理列表中的節點,所有類符號被輸入到外圍作用域的符號表中,若找到package-info.java,將其頂局樹節點加入到待處理列表中,確定類的參數(對泛型類型而言)、超類型和接口,根據需要添加默認構造器,將類中出現的符號輸入到類自身的符號表中,分析和校驗代碼中的注解(annotation)。
添加的默認構造器如下。
-
在第二階段,類使用MemberEnter.complete()來完成。類是按需完成的,但是未按照此方式完成的類最終都會通過處理未完成的隊列來完成。完成需要:(1)決定類的變量、超類和接口。(2)將類中定義的所有符號輸入,但是在第一階段已經完成的符號變量除外。(2)依賴于(1)中的類及其所有超類和封閉類已經完成。這就是為什么在(1)之后,我們將類放入到一個半完成的隊列中。只有當我們對一個類及其所有超類和內部類執行了(1)之后,我們才繼續執行(2)。
-
輸入所有的符號后,在這些符號上遇到的所有注解將會分析和驗證。
第一階段是組織被遍歷所有編譯的語法樹,而第二階段是按需的,類的成員在第一次訪問類的內容時輸入,這是通過在編譯類的類符號中使用completer對象來實現的,編譯類調用對應類的樹的MemberEnter階段。
注解處理
注解是JDK1.5中引入的,對于注解的處理可以理解為編譯器的一組插件,根據注解解析結果對抽象語法樹進行修改,如lombok。方法processAnnotations是注解處理的入口,當由注解需要處理時,則由JavacProcessingEnvironment#doProcessing方法創建一個JavaCompiler對象來完成。從概念上來講,注解處理是編譯之前的一個初步步驟。這個初步動作由一系列的循環組成(如圖2)。每個循環用于解析和輸入源文件,然后確定和調用適當的注解處理器。在首次循環之后,如果被調用的任何注解處理器生成任何需要作為最后編譯一部分的新原文件或類時,將需要執行后面的循環。最后,當所有必要的循環完成,執行實際編譯。
在實際中,調用任何注解處理器的需要可能要等到要編譯的文件被解析并且包含的聲明被確定之后才能知道。因此,為了避免在不執行注解處理的情況下不必要地解析和輸入源文件,JavacProcessingEnvironment對概念模型的執行有點不同,但是仍滿足注解處理器作為一個整體在實際編譯前執行。
當class文件被編譯,并且已經解析和填充符號后。JavacProcessingEnvironment將會被調用。該類決定被編譯的文件哪些注解需要被加載或被調用。通常,如果在整個編譯過程中出現任何錯誤,該過程則在下一個合適的點停止編譯。但是,如果在符號解析階段出現丟失符號,則會拋出異常,因為定義這些符號可能作為注解處理器的結果。
如果要運行注釋處理器,將在單獨的類加載器中加載并運行它們。
當注解處理器運行時,JavacProcessingEnvironment決定是否需要另外一輪注解處理。如果需要,將會創建一個新的對象JavaCompiler。讀取上步驟新生成的源文件進行解析。并且重新使用之前的語法樹進行解析。所有的這些樹都被輸入到這個新編譯器實例的符號表中,并且根據需要調用注解處理器。然后重復直到所有的注解編譯完成。
最后,JavacProcessingEnvironment返回JavaCompiler對象用于編譯剩下的部分。這個對象是用于解析和輸入初始文件集的原始實例,或者是JavacProcessingEnvironment創建的用于開始最后一輪編譯的最新實例。
下面以lombok為例說明
2. 注解處理后
分析和生成字節碼
當命令行中指定的所有文件被解析并輸入到編譯器的符號表中,并且注解也已經處理,JavaCompiler能處理分析的語法樹,以生成相應的class文件。由delegateCompiler.compile2()方法進入。
/*** 注釋處理之后的階段:屬性、解語法糖,最后是代碼生成。*/private void compile2() {try {switch (compilePolicy) {case ATTR_ONLY://只需解析數據的屬性attribute(todo);break;case CHECK_ONLY://用于屬性和解析樹的流分析檢查flow(attribute(todo));break;case SIMPLE://流分析、語法糖處理、生成字節碼generate(desugar(flow(attribute(todo))));break;case BY_FILE: {Queue<Queue<Env<AttrContext>>> q = todo.groupByFile();while (!q.isEmpty() && !shouldStop(CompileState.ATTR)) {generate(desugar(flow(attribute(q.remove()))));}}break;case BY_TODO:while (!todo.isEmpty())generate(desugar(flow(attribute(todo.remove()))));break;default:Assert.error("unknown compile policy");}} catch (Abort ex) {if (devVerbose)ex.printStackTrace(System.err);}if (verbose) {elapsed_msec = elapsed(start_msec);log.printVerbose("total", Long.toString(elapsed_msec));}reportDeferredDiagnostics();if (!log.hasDiagnosticListener()) {printCount("error", errorCount());printCount("warn", warningCount());}}當分析樹時,可以找到對成功編譯所需的類的引用,但是這些類沒有顯示指定用于編譯。根據編譯選項,將在源路徑和類路徑中搜索此類的類定義。如果能在類文件中找到定義,將自動分析、輸入源文件并將其放到待辦事項列表中。這些在Attr.SourceCompleter類中實現。
分析樹和生成類文件的工作由一系列的觀察者來處理進入了編譯器代辦事項列表。這些觀察者沒有必要分步對所有的源文件處理。事實上,內存問題會使這極不可取。唯一的要求是,“代辦”列表最終會被每一個觀察者處理,除非編譯因為錯誤而提前終止。
Attr和Check
頂層類是“Attribute",使用Attr,這意味著語法樹中的名稱、表達式和其他元素將被解析并與相對應的類型和符號相關聯。這可以通過Attr類或Check類檢查到許多語義錯誤。
語法分析的一個步驟,將語法樹中名字、表達式等元素不變量、方法、類型等聯系到一起,檢查變量使用前是否已聲明,推導泛型方法的類型參數,檢查類型匹配性,迕行常量折疊。
下面舉例說明。
(1)標注前。
(2)標注后。
Flow
如果到目前沒有錯誤,將會使用Flow進行類的流分析。流分析用于檢查變量的明確分配和不可到達語句。檢查所有checked exception都被捕獲或拋出;檢查變量的確定性賦值(1)所有局部變量在使用前必項確定性賦值;(2)有返回值的方法必須確定性返回值;檢查變量的確定性不重復賦值(1)為保證final的語義。
TransTypes
將泛型類型的類轉變為TransTypes類(裸類型,普通的java類型),同時插入必要的類型轉換代碼。
下面給個示例。
(1)類型轉換前。
(2)類型轉化后。
Lower
語法糖使用Lower類來處理,它重寫語法樹,通過替換等價、簡單子樹來消除特定類型的子樹。這將會處理內部類和嵌套類,類字面量,斷言,foreach循環等。對于每個被處理的類,Lower類返回已轉變類及所有轉變的嵌套類和內部類的樹的列表。盡管Lower通常處理頂層類,但也處理package-info.java的頂層樹。對于這種樹,Lower類將創建合成類來包含包的任何注解。
削除if (false) { … }形式癿無用代碼。滿足下述所有條件的代碼被認為是條件編譯的無用代碼?if語句的條件表達式是Java語言規范定義的常量表達式?并且常量表達式值為false則then塊為無用代碼;反之則else塊為無用代碼。
示例
(1)Lower前
(2)Lower后
Gen
Gen類用于方法代碼的編譯,它創建包含字節碼的Code屬性,通過JVM實例來執行方法。如果該步驟成功,則編譯后的類由ClassWriter類寫出。
一旦一個類作為類文件被寫出來,它的許多語法樹和生成的字節碼就不再需要了。為了節省內存,對樹的這些部分和符號的引用將為空,以允許垃圾收集器恢復內存。
整個前端編譯過程如下圖所示。
以上步驟已經生成了.class文件。在運行期間,編譯器將會進一步優化,即JIT優化。
JIT編譯
JIT是即時編譯器(Just In Time Compiler)的縮寫,Hotspot中有兩個即時編譯器,分別為Client Compiler(C1 JIT)和Server Compiler(C2 JIT),C1和C2都是將字節碼編譯成本地代碼,區別可以理解為C1是局部優化,而C2可以理解為專門面向服務端的。JVM有三種運行模式,分別是解釋(interpreted mode)、編譯模式(compiled mode)和混合模式(mixed mode)三種模式。**Java1.8中默認的解釋器與其中一個JIT編譯器直接配合的方式執行,即采用混合模式。**用戶可以通過參數"-Xint"強制虛擬機運行在解釋模式,此時編譯器不工作。當然也可以使用參數"-Xcomp"強制虛擬機運行于“編譯模式”。這時優先采用編譯方式執行,但在某些情況下,解釋器不得不介入才能執行。
編譯條件
編譯優化的條件主要針對熱點代碼,而熱點代碼主要有兩種情況:
無論第一種情況還是第二種情況,都是以整個方法作為編譯對象。第二種情況而不是以循環體作為編譯對象。只是處理方式不同,因為第二種編譯方式發生在方法執行體中,而在運行時表現為方法棧,通過替換方法棧中的部分代碼為編譯后的本地代碼,即通過棧上替換(On Stack Replacement,OSR)的方式進行JIT編譯。
很顯然,無論采用哪種方法,編譯器都需要識別哪些代碼為熱點代碼。目前熱點代碼探測的方式有兩種。
在HotSpot中采用的是第二種方式,且對同一個方法采用了兩個計數器。一個是記錄在某段時間內方法調用次數的計數器,當某段時間內不滿足編譯時,則次數會衰減一半,所以是某段時間內的相對次數。另一個是記錄方法中的循環體的計數器(稱為回邊計數器),而這個計數器會一直往上增長,是絕對計數,當溢出時,則調整計數器的值為溢出狀態。當該兩個計數器超過默認的閾值,則發生JIT編譯。下面表格是不同編譯模式下的默認值。兩個計數器都可以通過虛擬機參數進行設定。
| 1500次 | 13995次 | |
| C2 | 10000次 | 10700次 |
編譯過程
默認情況下,當虛擬機中的編譯線程編譯完成后,才能替換到JIT編譯請求。用戶可以通過參數-XX:-BackgroundCompilation來禁止后臺編譯。
C1編譯優化主要在AdvancedThresholdPolicy.cpp文件中。
編譯優化技術
公共子表達式消除、方法內聯、逃逸分析
參考
1、http://openjdk.java.net/groups/compiler/doc/compilation-overview/index.html
2、(重要文章)JVM c1, c2 compiler thread — high CPU consumption? https://medium.com/@RamLakshmanan/jvm-c1-c2-compiler-thread-high-cpu-consumption-b99acc604f1d
3、(javac編譯操作)Compile All Java Classes in Directory Structure with javac,https://www.baeldung.com/javac-compile-classes-directory
4、(內容同3)Compile Java Files. https://www.baeldung.com/javac
5、https://www.oracle.com/java/technologies/whitepaper.html
總結
以上是生活随笔為你收集整理的JVM(一)一文读懂Java编译全过程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python安装psutil_psuti
- 下一篇: tp-link885n转发规则虚拟服务器