爆爆:Java程式碼編譯流程是怎樣的?

語言: CN / TW / HK

前言

寫了這麼多年的程式碼,對於java程式碼執行的全流程你心裡有清晰的脈絡嗎?

大家會不會跟我最開始一樣,覺得在IDE裡點一下RUN按鈕,我們寫的程式碼就直接直接跑起來了吧?

俗話說的好,你覺得生活靜好,其實只是因為有人在為你負重前行,編譯器和虛擬機器默默的承受了這一切。

小小的一個RUN,背後卻是很多元件共同努力的結果,它們必須非常努力,才能看起來毫不費力。

今天就讓我們花點篇幅,來好好聊聊,Java程式碼RUN起來的背後,那些默默付出的大功臣們。

當我們寫下一行程式碼時,我們到底在寫什麼?

夜深了,我們在螢幕上打下一段優雅的程式碼,一邊擰開泡著枸杞的保溫杯抿了一口熱水,一邊欣賞自己詩一樣的程式碼,心裡默默地誇了一波自己:不愧是我!

第一個問題來了,計算機真的能看到我們寫的”詩“嗎?

眾所周知,Java是一門"一次編寫,到處執行"的語言,也就是所謂的平臺無關性,不管在哪個平臺都能夠執行,且保證執行的結果與期待的一致。(這是大學老師反覆強調的)

Java實現”平臺無關性“的原理也非常簡單,就是利用中間格式來進行過渡,也就是我們常說的位元組碼,通過將Java原始碼轉換成位元組碼,保證JVM(Java虛擬機器)讀取到的一定是自己能夠識別的位元組碼格式。

一個通俗的解釋:你不會說法語,法國人不會講中文,但是你們或多或少都會點英語,把英語作為你們的中間格式,保證雙方都能明白對方的意思,這就是所謂的跨平臺。

Java原始碼首先被編譯成位元組碼,而這個位元組碼就是實現平臺無關性的關鍵,無論你是什麼型別的平臺,只要你安裝了能夠識別字節碼的JVM(Java虛擬機器),通過JVM對位元組碼檔案進行解析,把位元組碼轉換成具體平臺上的機器指令,就可以實現跨平臺的運行了。

因此別說讓計算機底層讀到我們寫的”程式碼詩“了,就連Java虛擬機器都拿不到我們原汁原味的程式碼,在編譯器的努力下,Java原始碼已經變成大白話的class檔案了。

所以啊寶,作業系統欣賞不到我們”詩一樣的程式碼“,我們所寫的每一行程式碼,都會變成一條條指令,對作業系統來說,它看到的不是程式設計的藝術,只是自己需要完成的一條條KPI罷了。

文字即程式碼?

如果我們寫了具有同樣內容的Java檔案和txt文字,他們在文字編輯器中長得是沒有區別的。

有一句名言是:世界上最好的IDE是txt文字編輯器。現在我們可能用IDE都用順手了,很多的操作我們都習慣於讓IDE給我們提示,依賴於IDE的程式碼補全和快捷鍵。

但在傳說中,有一群用記事本就能打出優美程式碼的大佬,到了這個境界時,已經是人碼合一,無需語法高亮,無需補全提示,所有的正確語法都瞭然於心,打出來的每一行程式碼都是可以直接編譯run起來且零BUG的好程式碼(doge)。

扯得有點遠了,但用記事本確實是可以實現開發功能,只要你自己打的程式碼邏輯正確,且沒有語法錯誤,最後儲存的字尾是.java,就能作為程式碼去運行了。

因此,從本質來說,我們所打出來的txt文字和Java程式碼在一開始是沒有多大區別的,用普通的文字編輯器也能開啟我們的.java字尾的檔案。但是文字編輯器能做到的也僅僅限於看到.java檔案裡面的程式碼文字而已了。

Java編譯器才是最終,能夠識別並理解.java檔案的存在。

Java程式碼想要執行起來,第一步就是得到編譯器的認可。編譯器的任務很簡單,就是將符合Java語言原始碼編譯為符合 Java虛擬機器規範的Class檔案,如果輸入的Java原始碼不符合規範則需要報告錯誤。

可以說,編譯的過程是Java開發的第一小步,但也是程式的一大步。

接下來我們先介紹一下編譯器在Java體系中的位置。

JDK與JRE的愛恨情仇

在我們初學java時,一定安裝過所謂的java環境,當我們自信滿滿地點進了Oracle的Java官網,映入眼簾的是兩個看起來很像的安裝包:

這我就矇蔽了呀,我就想裝個Java環境,怎麼有兩個奇奇怪怪的安裝包,一個叫JDK,一個叫JRE,這兩個安裝包跟俗稱的”Java“又有什麼關係?

先理清楚所謂的JDK和JRE到底有什麼區別吧,來看一張Java 8的體系架構圖(https://docs.oracle.com/javase/8/docs/):

jdk8體系架構圖

JDK全稱是Java開發工具包(Java Development Kit),它包含了Java從開發到執行的各種工具。

JRE指的則是Java執行環境(Java Runtime Environment),它包含了基礎類庫和JVM虛擬機器。

上圖展示的是Java 8的體系結構,最左邊的一欄很清晰的表明了JDK和JRE各自的範圍,我們也很容易發現:

JRE是JDK的子集。

既然你要搞開發,肯定得保證自己寫的程式碼能執行起來吧,所以當開發人員安裝好JDK之後裡面已經包含了一個執行環境JRE,保證自己的程式碼能夠得到執行和驗證,這就是為什麼JRE被包含在JDK中。

但如果我們是普通使用者,並不關心開發,甚至根本不懂程式碼,我只想要程式碼跑起來的結果,那隻需要本地有JRE執行環境就行了。

如果用過零幾年的按鍵手機,你就會深有體會,那時候很多的手機軟體都是用Java編寫的,只需要一個JAR包,你就能收穫快樂。

手機Java應用

反向思維一下,既然安裝JRE就能執行JAVA程式碼,但要需要完整的JDK才能完成開發,那他們之間的差集肯定跟開發的過程有關。

所以接下來,我們來探討一下為什麼缺少這一塊內容就只能成為執行環境,而不能承擔開發功能呢?

JDK和JRE的差集

這一塊裡我們可以看到幾個很熟悉的命令:

javac:用於編譯java原始碼,生成class檔案;

javap:用於反編譯,根據class檔案,反解析出其中的彙編指令和其他資訊;

javadoc:用於生成java文件的命令。

其中,我們最常用的、最重要的就是javac命令。這是JDK中內嵌的編譯器,通過這個命令,可以將java原始檔轉換成class檔案。這個javac編譯器就是JRE相比於JDK少了開發功能的決定性元素!!

我們用一個簡單的例子看看,開發者編寫好的java程式碼在完整的JDK架構下,經過JDK、JRE以及JVM的執行過程。

java程式碼執行的簡單示例

可以看到,通過JDK中的javac命令,我們才能將java原始碼編譯成class檔案,而前面也提到了,這個class檔案才是最終放到JVM中執行的檔案。

我們把java原始碼到class檔案的過程稱之為編譯階段,把class檔案到JVM中執行得到結果的階段稱為執行階段。

因此,如果只有JRE而沒有完整的JDK的話,相當於就少了編譯原始碼的關鍵工具,你只能依賴人家傳遞的,已經編譯好的class程式碼,將程式執行起來,而不具備修改、開發的能力。

聰明的你很快就能發現,既然虛擬機器執行需要的其實是class檔案,因此它對於最前面用的是什麼語言其實並不關心,只要支援生成JVM能夠識別的位元組碼就行了。

難道說……

沒錯,恭喜你發現了JVM虛擬機器**”跨語言“的特性**。

很多語言依賴了這種特性,將自己本身的原始碼,編譯生成class檔案,並基於JVM虛擬機器執行。比較常用的有Scala和Kotlin等,它們甚至可以跟Java語言相互呼叫,因為最終都是要編譯成class檔案到虛擬機器中執行嘛,所以即使在原始碼階段是不同的語言,經過編譯器之後,大家都變成了一樣的位元組碼。

多語言轉換為位元組碼

當然,要是再極端一點,由於class檔案本質上也是一個二進位制的檔案,因此只要你足夠強,能夠徒手寫出自己需要的二進位制檔案,你也就不再需要編譯器了(狗頭保命)。

很多讀者就要說了:”我們是來學技術的,不是來學仙術的“。

先別笑,直接改位元組碼並不是什麼天上飛的仙術,而是實打實的技術。像我們熟悉的lombok,就能夠根據我們編寫的註解生成位元組碼,實現位元組碼的修改增強(但lombok也是利用了編譯器的一些特性,是在編譯階段觸發操作的)。

類似的還有諸如ASM等一些位元組碼增強技術,也是通過直接操作位元組碼來實現的。

通過位元組碼增強技術可以實現熱部署等操作,讓你修改程式碼之後無需重啟服務就能生效;也可以實現日誌注入等功能,在不需要改變客戶端呼叫方式情況下完成對指定方法增加快取或日誌的功能。

但對於大部分的普通開發者來說,編譯器還是必不可少的。

編譯階段當呼叫javac命令,觸發java程式碼的編譯過程,將.java檔案編譯成了.class二進位制檔案。

那麼,在編譯器中,原始碼到底是怎麼一步步變化的呢。

注意:javac是javac編譯器的自帶的命令,但市面上可用的並不只有javac這一種編譯器,有一些其他的廠商也根據java的標準開發了自己的編譯器。例如Eclipse的ecj(the Eclipse Compiler for Java)等。

只是大部分人用的都是JDK自帶的javac的編譯器,因此下文的討論都是基於javac編譯器展開的。

可以這樣理解,編譯的過程就是”編“和”譯“。

編:將java原始碼的結構組織成合適的格式,包括編譯過程中的抽象語法樹和符號表等,並在最終將原始碼編碼成為class檔案。

譯:對原始碼中的語義進行解析,並準確地翻譯成另一種形式(位元組碼)。這一步既要確保原格式正確(Java原始碼中的語法正確),又要確保翻譯後的位元組碼跟源程式碼表達的意思一致。

也就是說,編譯的過程要保證 輸入的格式符合Java語言規範,輸出的格式符合Java虛擬機器規範。

這個過程說起來複雜,但是讀者可以回憶一下自己經歷過的程式碼編譯失敗的場景,每一次編譯失敗都是編譯器在默默工作的結果,不同的錯誤可能是在編譯過程的不同階段被發現並丟擲的。

接下來,我們循序漸進地告訴大家編譯的具體步驟,以及編譯過程的各個階段丟擲的不同編譯異常。

編譯過程呼叫圖

東西看起來很多哈,總結起來大概可以分為下面幾個步驟:

1. 詞法分析&語法分析詞法分析是最開始的一步,主要的作用就是把原始碼的字元流轉換成Token集合,Token是指程式碼中具有獨立語義且不可再分的標記。

這裡要注意,一個Token指的並不是單個的字元,而是具有實義的詞。而且,編譯器還會識別不同的詞法型別,為它分配對應的Token型別,比如,int就會被識別為Token.INT ,運算子也會被分配為對應的Token型別,例如+就是Token.PLUS:

詞法分析

當代碼被解析為一系列的Token集合之後,下一步是進行語法分析。

語法分析是根據解析後的Token集合,解析出抽象語法樹(Abstract Syntax Tree, AST),AST中包含了java程式碼中的層級結構。

小知識:在NLP等領域的研究中,語法樹也是用來分析語法規則及原理的重要手段,在這裡不過多闡述。

語法分析1

根據這個結構,可以層級地展示程式碼中所有的變數、方法甚至是註釋等各種資訊。

構建AST的過程會判斷Token的型別與其在樹中的位置是否匹配,這一步我們很好理解哈,你用關鍵字作為變數名稱的時候編譯會不通過,就是在這一步被逮到的。

例如,你用這樣一段程式碼去編譯:

public class Hello { public static void main(String[] args) { String enum = "world"; System.out.println("Hello world"); }}

會報如下的錯誤:

error: as of release 5, 'enum' is a keyword, and may not be used as an identifier

因為enum是關鍵字,構建語法樹的時候發現堂堂一個關鍵字居然出現在了識別符號的位置,這可使不得啊!

因此AST樹構建失敗,編譯報錯。

詞法分析&語法分析是對原始碼中文字的抽象,將.java原始碼中的文字結構按照編譯器特定的規則拆分、解析,為後續的編譯工作鋪平了道路,後面的操作都離不開這個AST。

2. 填充符號表符號表就是由符號地址(位置)和符號資訊構成的”表格“,它儲存的是標識所對應的型別、作用域等。

這裡說它是”表格“可能會對讀者產生一定的誤解,實際上它不是像我們想象的那種二維的表格,而是更接近hashTable那樣的鍵值對結構,符號表可以由陣列、樹狀結構或者棧等各種結構來實現。

這個符號表在後續的很多步驟都能發揮作用,例如:

static char x; int foo() { int x; { float x; } }

這段程式碼有三個同名變數,聰明的讀者肯定能夠分辨它們各自的作用域,但是笨笨的計算機沒辦法那麼快分清它們的區別。

為了在解析符號和型別的時候分清它們的作用域而不產生使用衝突,就需要通過符號表來記錄關係。

填充符號表的過程可以描述為:

將每個AST的頂層節點都放到待處理的列表中,並逐個處理;

將所有的類符號(類的宣告,名稱)都輸出到外層的作用域的符號表中;

如果發現有package-info.java檔案(描述整個包的資訊和包內的常量),將其頂層節點放到待處理的列表中;

明確泛型型別的真實型別;

如果類中沒有任何構造器,則新增預設的無參構造器;

將類中符號輸入到類自身的符號表中。

這一步有點抽象了,大家也不用太糾結於細節,能夠明白大概的流程和目的就行了,只需要理解,這一步就是為了生成記錄了類中符號的型別、屬性等資訊的符號表,方便後續流程中的應用。

強調一下5,學過java基礎的都知道,如果一個類沒有定義構造器,則會預設一個預設構建無參構造器,新增預設構造器的操作也是在填充符號表時完成的。

為什麼呢?

很簡單,因為類的構造方法也是需要放到符號表裡記錄的,而且不能為空,既然你沒有指定,那我就給你放一個預設的空參構造器,然後記錄到符號表咯。

相關的原始碼就放著這裡了,大家有興趣可以深挖一下。http://hg.openjdk.java.net/jdk8u/jdk8u/langtools/file/2baeb96fa198/src/share/classes/com/sun/tools/javac/comp/Enter.java

3. 註解處理自從JDK 5以來,Java提供了對註解的支援,現在程式中使用註解已經是非常常規的操作。

然而要注意的是,並不是所有的註解都是在編譯期起作用的,我們平時用反射處理的註解主要是指執行時註解,執行時註解在編譯期不受影響,在編譯之後的class檔案中還是會保留,最終要在class檔案到JVM執行的過程中才生效。

而編譯期註解是指以@Retention(RetentionPolicy.SOURCE)定義的,在編譯期就處理了的註解,這一類註解不會保留到class檔案中。

聽起來很懵,但其實編譯過程中這一步註解處理其實大家在無意中已經接觸過很多次了,比如大家常用的lombok,就是在這一步起作用的。

lombok採用的就是編譯期註解處理的方法,因此當我們編譯好用了lombok註解的.java檔案後,開啟生成的class檔案就可以看到lombok相關的註解已經消失,而相應的getter、setter方法則已經被注入到class檔案中。

上圖中右圖展示的並不是class檔案,而是與新增lombok註解等效的原始碼,左右兩側的程式碼生成的位元組碼是一致的。

在這一步,lombok的註解處理器生效,並對我們前面所說的抽象語法樹AST進行增強處理。

首先找到@Data註解所在類對應的語法樹(AST),然後修改該語法樹(AST),增加getter和setter方法定義的相應樹節點,實現我們所需的功能。

這一步也是為數不多的,編譯器留給程式設計師自己編寫程式碼來影響原始碼編譯過程的機會。

註解處理完成後,可能又會產生新的符號,因此如果執行了註解處理,需要再執行一次解析和填充符號表的操作(回到第2步)。

4. 語義分析語義分析聽起來跟第一步詞法分析&語法分析看起來很像,但其實是有很大區別。

我們類比成語文來解釋:

敖丙說:”吃你飯今天了嗎?“。

詞法分析的步驟相當於把這一句話拆成了你、吃、今天、飯、了、嗎、?,這幾個詞語。每個詞都沒問題。

可是到了語義分析階段,我們再根據規則檢查這句話的語義,發現這句話其實是不通順的。

回到編譯過程中來解釋,語義分析的功能就是從結構和規則上對原始碼進行檢查,包括宣告檢查和型別檢查等等。

這裡我們用周志明老師書中的一個例子來說明:

假設有如下3個變數定義的語句:

int a = 1; boolean b = false; char c= 2; int d =a + c; int e = b + c; char f = a + c;

這一段程式碼能夠通過第一步的詞法分析和語法分析,並構成正確的AST,但是在語義分析中會報錯。因為編譯器發現變數e和f的運算都是不符合規範的,參與運算的兩個值的型別不匹配該運算子的邏輯。

語義分析更進一步檢查上下文中變數的規範性,例如變數是否已經宣告,變數的資料型別與其參與的運算是否匹配等等。

如果要對語義分析做細分的話,可以分為以下幾個小階段:

4.1 標註檢查

這就是剛才說的,檢查變數是否事先宣告以及運算型別是否匹配的步驟,而且這一步的處理會影響到AST的結構:

注意圖中所示,我**們首先需要檢查變數a有沒有宣告(宣告檢查),並檢查a的型別(型別檢查),這兩個檢查都需要用上我們前文已經填充完成的符號表,從符號表中查詢變數的作用域和型別,**完成語義分析的檢查。

然後判斷運算子和另一個運算值的型別,檢查左右運算值的型別是否匹配,能否參與運算。

看到了嗎,在這裡AST和符號表就共同發揮作用啦。

此外,標註檢查步驟還有兩個很重要的操作:

泛型方法型別的推導:

在這一步就需要明確泛型方法傳遞的真實型別是什麼了;

常量摺疊(Constant Folding):

這是一個很有意思的操作,它會進行一些簡單的常量計算,例如:int a = 1 + 2;在這一步就會被優化為a = 3,優化之後在AST中還是能夠看到int、a、1、+、2、;這幾個標記,但是這個表示式的值已經被計算出來了,並在AST上進行了標註。也就是說,現在的AST既保留了表示式的結構,也記錄了表示式的結果。

當後續到虛擬機器中去執行位元組碼的時候,由於編譯期常量摺疊的優化,int a = 3和int a = 1 + 2的執行效率其實是一樣的,因為這一個常量的運算在編譯期已經做完,不會再額外消耗執行期的處理時間。

一般的程式碼優化都是要到生成位元組碼之後,等到執行期在虛擬機器的直譯器中再進行的。而常量摺疊是javac編譯器對原始碼做的極少量的優化措施之一,也是為數不多的編譯期對程式碼進行優化的操作。

4.2 資料流分析資料流分析是在標註檢查之後的進一步檢驗,主要檢驗是區域性變數在使用前是否確定性賦值、宣告有返回值的方法是否有確定性的返回值等。

值得注意的是,final變數不可重複賦值的性質也是在這一步檢查,如果一個final變數被重複賦值,編譯器會發現並報錯的。也正是因為這個特性,用final關鍵字區域性變數只會在編譯期去校驗,不會對在執行期產生任何作用 。

有如下的例子:

// 方法1public void aobingTest(final int nezha){ final int a = 0;}// 方法2public void aobingTest(int nezha){ int a = 0;}

這兩個方法產生的位元組碼是一模一樣的,沒有任何的差別。因此所有的final不可重複賦值的限制,都在編譯期得到了檢驗,如果宣告為final的區域性變數被重複賦值,在編譯期就會報錯,如果沒有發現有final重複賦值的錯誤,才會成功生成位元組碼。

因此對於執行期來說,區域性變數是否宣告為final,不會有任何校驗的步驟(因為區域性變數不管有沒有用final限制,生成的位元組碼都是一樣的,位元組碼中不會保留區域性變數是否宣告為final的資訊)。

5. 解語法糖簡單地來說,語法糖就是方便程式設計師編寫的便捷寫法,這種語法不會對最終的結果產生實際影響,但能夠減少程式編寫者的工作量。

例如,java中的自動拆箱裝箱功能、foreach迴圈功能等,都是為了程式設計師能夠更寫出更簡潔流程的程式碼而封裝的語法糖。

但是到了程式執行階段,這樣的語法糖對計算機來說是不可識別的。因此需要在編譯階段先解語法糖,將語法還原為它本來”笨拙“的樣子。

例如,將包裝型別拆成普通型別,將增強for迴圈替換為普通的for迴圈。

6. 生成Class檔案終於到了生成最終需要的class檔案的一步了,前面所構建的語法樹、符號表等資訊,在這一步被轉換成位元組碼指令寫到class檔案中,除此之外,還有兩個非常重要的方法被新增到語法樹中,他們分別是和方法。

注意,這兩個長得像init的方法指的並不是類中的建構函式。

方法是一個類的構造器,它的作用是初試化所有的靜態變數並執行用static {}包裹的程式碼塊,而且該方法的收集是有順序的:

將這些與類相關的初始化程式碼按順序收集在一起生成了函式,在類載入的時候按順序執行,所以方法相當於是把靜態的程式碼打包在一起,等待後續統一執行。

父類靜態變數初始化

父類靜態語句塊

子類靜態變數初始化

子類靜態語句塊

方法其實是一個例項構造器,它的作用是初始化類中的成員變數,例如成員變數的賦值操作,以及被{}符號包裹的程式碼塊,這些方法都會被收斂到方法中成為一個跟物件初始化相關的方法。該方法的收集也是有順序的:

父類程式碼塊

父類建構函式

子類變數初始化

子類程式碼塊

子類建構函式

父類變數初始化

通俗來說,這兩個方法就是將原始碼中的程式碼塊和變數初始化的步驟按照靜態與非靜態分為了兩類,並按一定順序打包好,等待合適的時機執行。

對方法來說,這個合適的執行時機就是在類被載入的時候;

而對方法來說,執行的時機就是在該類new一個物件的時候。

由於類載入過程優先於物件例項化過程,所以方法一定比方法先執行。因此它們完整的執行順序就是:

父類靜態變數初始化

父類靜態語句塊

子類靜態變數初始化

子類靜態語句塊

父類變數初始化

父類語句塊

父類建構函式

子類變數初始化

子類語句塊

子類建構函式

發現了嗎,這就是常見的面試題:”java程式碼的載入順序“的標準答案。

這個問題的本質其實在於:Java程式碼能夠保持載入順序的原因就是在生成class檔案時,將按順序拼接好的和方法新增到了class檔案中,在後續的執行過程中再按順序執行。

以後面試遇到這個問題知道怎麼答了嗎。

除了生成構造器之外,生成class檔案時還會優化某些程式碼邏輯的實現方式,比如,將字串的+運算操作,替換為StringBuffer或者StringBuilder的append()方法。

到此為止,java原始碼到class檔案的編譯過程進入了尾聲。

由於篇幅原因,今天暫時講到Java程式碼編譯為class檔案的過程,後續我們再繼續鑽研class檔案中的細節以及位元組碼最終在JVM中執行的流程。

一些思考對了,還有一個問題可能是大家理解上的誤區。

很多人會認為class檔案 = 位元組碼,這是不對的,class檔案並不等於位元組碼。我們從class檔案的結構中可以窺見端倪,class檔案中記錄瞭如下的一些資訊:

結構資訊:class檔案格式版本號;

元資料:主要對應的是Java原始碼中”宣告“和”常量“對應的資訊,包括類的宣告資訊、類中屬性域與方法的宣告資訊、常量池等;

方法資訊:主要對應Java原始碼中”語句“和”表示式“對應的資訊,包括 位元組碼、異常處理器表、運算元棧和區域性變數區的大小等;

這下就很清晰了,位元組碼是Class檔案的一個子集,只是class檔案中眾多組成部分的其中之一。

乖,以後別再以為Class檔案就是位元組碼了。