首頁  >  文章  >  Java  >  Java程式的生命史

Java程式的生命史

怪我咯
怪我咯原創
2017-04-05 16:45:031334瀏覽

 作為程式猿 ,我們每天都在寫Code,但你真的了解它的生命週期麼?今天就來簡單聊聊它的生命歷程,說起一段Java Code,從出生到game over大體分這麼幾步:編譯、類別載入、運行、GC。

 

 編譯

  Java語言的編譯期其實是一段「不確定」的過程,因為可能是前端編譯器把.java檔轉變為.class檔的過程;也可能是指JVM的後端運行期編譯器(JIT編譯器)把字節碼轉變為機器碼的過程;也可能是指使用靜態提前編譯器(AOT編譯器)直接把.java檔編譯成本地機器碼的過程。但這裡我們說的是第一類。也是符合我們大眾對編譯認知的。編譯在這個時段經歷了哪些過程?

  詞法、語法分析

  詞法分析是將原始碼的字元流轉變為Token集合,而語法分析則是根據Token序列抽象構造語法樹(ATS)的過程,ATS是一種用來描述程式碼語法結構的樹形表示形式,語法樹的每個節點都代表著程式碼中的一個語法結構,例如套件、類型、修飾符、運算子介面、回傳值甚至程式碼註解都可以是一個語法結構。

  填入符號表

  完成了語法和詞法分析之後,下一步就是填入符號表的過程,符號表中所登記的資訊在編譯的不同階段都要用到。在這裡延伸一下符號表的概念。符號表是什麼呢?它是由一組符號位址和符號資訊構成的表格,最簡單的可以理解為哈希表的K-V值對的形式。為什麼會用到符號表呢?符號表最早的應用之一就是組織程式碼的資訊。最初,電腦程式只是一串簡單的數字,但程式猿很快就發現使用符號來表示操作和記憶體位址(變數名稱)要方便得多。將名稱和數字關聯起來就需要一張符號表。隨著程式的成長,符號表操作的效能逐漸變成了程式開發效率的瓶頸,為此從而誕生了許多提升序號表效率的資料結構和演算法。至於所謂的資料結構和演算法有哪些呢?大體說下:無序鍊錶中的順序查找、有序數組中的二分查找、二元查找樹、平衡查找樹(在這我們主要接觸到的是紅黑樹)、散列表(基於拉鍊法的散列表,基於線性探測法的散列表)。像Java中的java.util.TreeMap和java.util.HashMap分別是基於紅黑樹和拉鍊法的散列表的符號表實現的。這裡提到的符號表的概念不再細說,有興趣的可以找相關資料。

  語意分析

  經過上兩步驟之後,我們得到了程式碼的抽象語法樹表示,語法樹能表示一個正確的原始碼抽象,但無法保證原始程式符合邏輯的,這時候語意分析登場了,它的主要任務就是對結構上正確的原始程式進行上下文有關性質的審查。標註檢查、資料及控制流程分析、解語法糖是語意分析階段的幾個步驟,在這具體說下語法糖的概念。語法糖是指在電腦語言中添加的某種語法,這種語法對語言的功能並沒有影響,但更方便程式猿使用。 Java中最常用的語法糖主要是泛型、變長參數、自從裝箱/拆箱、遍歷循環,JVM在運行時不支援這些語法,它們在編譯階段還原回簡單的基礎語法結構,這個過程也就是解語法糖。舉個泛型抹除的例子,List和List在編譯之後會進行泛型擦除,變成相同的原生型別List

  字節碼生成

  字節碼生成是Javac編譯過程的最後一個階段,在這個階段會把前面各步驟生成的信息轉換成字節碼寫到磁碟中,還會進行了少量程式碼添加和轉換的工作。實例建構器()方法和類別建構子()方法(這裡的實例建構器並不是指預設建構子,如果使用者程式碼沒有提供任何建構函數,那編譯器將會加入一個沒有參數的、存取性與目前類別一致的預設建構函數,這個工作在填充符號表階段已經完成,而類別建構器()方法指的是編譯器自動收集類中的所有類別變數賦值動作和靜態語句區塊中的語句合併產生的)就是在這個階段加入到語法樹的。到此為止整個編譯過程結束。

 類別載入

  編譯將程式編譯成字節碼之後,下一步就是類別載入到記憶體的過程。

  類別載入的過程是在虛擬機器內存的方法區進行,這地方涉及到虛擬機內存,所以在這首先簡單介紹下程式在內存區域分佈的概念。虛擬機器記憶體區域劃分為:程式計數器、堆疊、本地方法堆疊、堆疊、方法區(部分區域為運行時常數池)、直接記憶體。

  程式計數器

  程式計數器是一塊較小的記憶體空間,它可以看做是目前執行緒所執行的字節碼的行號指示器。在JVM概念模型中,字節碼解釋器工作時就是透過改變這個計數器的值來選取下一條需要執行的字節碼指令。

  棧

  棧用於儲存局部變數表、運算元堆疊、動態連結、方法出口等資訊。其中局部變數表存放了編譯期克制的各種基本資料型別物件引用。它與程式計數器一樣都是線程私有的。

  本機方法堆疊

  本機方法堆疊與上述介紹的虛擬機器堆疊作用相似,它們的差異不過是虛擬機器堆疊為虛擬機器執行Java方法(位元組碼)服務,而本地方法棧則為虛擬機器使用的Native方法服務,甚至有的虛擬機會將這兩塊合而為一。

  堆

  堆是JVM管理記憶體最大的一塊。它是被所有執行緒共享的一塊區域,它的唯一目的是存放物件實例,幾乎所有的物件實例都在這裡分配記憶體(像特殊的類別物件會在方法區分配記憶體)。這裡也是垃圾收集管理的主要區域,從記憶體回收角度來看,現在垃圾收集器都採用分代收集演算法(後面會詳細介紹),所以Java堆還可以進一步細分:新生代和老年代,而新生世代進一步細分:Eden空間、From Survivor空間、To Survivor空間。為了效率考慮,堆也可能劃分為多個執行緒私有的分配緩衝區(TLAB)。無論如何劃分,都與存放內容無關,無論哪個區域,存放的依然是物件實例,它們存在的目的只是為了更好的回收和分配記憶體而已。

  方法區

  方法區與堆疊一樣,都是線程共享的記憶體區域,它用於儲存已被虛擬機器載入的類別資訊、常數、靜態變數、即時編譯器編譯後的代碼等數據。而運行時常數池是方法區的一部分,它主要用於存放編譯期聲明各種字面量和符號引用。

  直接內存

  直接內存並不是虛擬機器運行時資料區的一部分,也是不Java規範中定義的內存區域,你可以簡單理解為堆外內存,內存分配不受Java堆大小的限制但受整個記憶體大小的限制。

  說完了虛擬機器記憶體區域的概念,我們回到正題,類別載入的流程到底是什麼呢?載入、驗證、準備、解析、初始化五步驟。其中載入、驗證、準備、初始化是順序執行的,而解析則不一定,它有可能會在初始化之後執行。

  載入

  在載入階段,JVM需要完成三個步驟:首先透過類別的全限定名稱來取得定義此類的二進位位元流,然後將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時間資料結構,最後在記憶體中產生一個代表這個類別的java.lang.Class對象,作為方法區這個類別的各種資料入口。在第一步獲取二進位位元組流中並沒有明確指出從一個*.class檔案中獲取,規定的靈活性導致我們可以從ZIP(為JAR、EAR/WAR格式提供基礎)包中獲取,從網絡獲取(Applet),運行時計算產生(動態代理),其他檔案產生(JSP檔案產生的Class類別),從資料庫取得。

  驗證

  驗證,顧名思義,其實就是為了確保Class文件位元組流中包含資訊符合JVM的要求,因為Class文件的來源途徑不一定中規中矩的從編譯器產生,也有可能用十六進位編輯器直接編寫Class檔。校驗流程為文件格式校驗、元資料驗證、字節碼驗證,這地方的具體安全校驗方式不再細說。

  準備

  準備階段正式為類別變數分配記憶體並設定初始值的階段,這些變數所使用的記憶體都在方法區進行分配。

  解析

  解析階段是JVM將常數池內的符號引用替換為直接引用(指向目標的指標、相對偏移量或句柄)的過程,前面我們談到的編譯填充符號表的價值在這地方體現出來了。解析過程無非就是對類別或介面、欄位、介面方法進行解析。

  初始化

  類別初始化階段是類別載入過程的最後一步,在準備階段,變數已經賦過一次初始值,而在這一步,則會根據程式猿定制的要求進行初始化類別變數和其他資源。在這個階段就是執行前面編譯字節碼產生流程提到的()方法的過程。虛擬機器也保證在多執行緒環境下這個方法被同時呼叫時被正確的加鎖、同步,保證只有一個執行緒去執行這個方法而其他執行緒阻塞等待,筆者以前寫的一篇文章《從一個簡單的Java單例範例談談並發》中,基於類別初始化的單例線程安全的寫法涉及到的就是這塊,有興趣的可以結合起來一起看看。這地方還牽涉到另一個我們比較關心的知識點,Java何時觸發對類別的初始化操作呢?

  • 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類別沒有初始化,則需要觸發其初始化,前面各種叉叉指令什麼鬼,簡單理解就是new一個物件的時候,讀取或是設定一個類別的靜態欄位的時候,呼叫一個類別的靜態方法的時候。

  • 使用java.lang.reflect套件的方法對類別進行反射呼叫的時候,如果類別沒有初始化,則需要觸發其初始化。
    當初始化一個類,發現其父類還沒進行初始化,則先觸發其父類的初始化操作。

  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類別(main方法所在類別),虛擬機會先初始化這個主類別。

  • 當使用JDK1.7以上的動態語言支援時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應類別沒有初始化,則觸發初始化操作。

 運行

  經過了上面兩個階段,程式開始正常跑起來了,我們都知道程式執行過程涉及到了各種指令的計算操作, 程式如何執行的呢?這地方就會使用到文章開頭談到的後端編譯器(JIT即時編譯器)+解釋器這種搭配使用的混合模式(HotSpot虛擬機預設採用了解釋器與一個編譯器),字節碼執行引擎則負責著這類各種程式計算操作的任務,它在執行Java程式碼的時候有可能會有解釋執行(透過解釋器執行)和編譯執行(透過即時編譯器產生本地程式碼執行)兩種選擇,也可能兩者兼備。棧幀是用來支援虛擬機器進行方法呼叫和執行的資料結構,具體的壓棧彈棧各種指令計算的思路涉及到了一個經典的演算法——Dijkstra演算法,至於如何執行有興趣的自己查資料吧這地方不會太深。運行期的最佳化問題在這個階段同樣重要,而JVM設計團隊則把對效能的最佳化集中到了這個階段,這樣可以讓那些不是由Javac產生的Class檔案同樣享受到編譯器最佳化帶來的好處,至於具體的優化技術有哪些呢?有很多,這裡簡單提幾個代表性的最佳化技術:公共子表達式消除、陣列邊界檢查消除、方法內聯、逃逸分析等等。

 GC

  終於說到程式要進入死亡階段了。 JVM是如何判斷程式藥丸的呢?這地方其實採用了可達性分析演算法,這個演算法的基本想法是透過一系列的稱為「GC Roots」的物件作為起始點,從這個節點開始向下搜索,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時(用圖論話說,就是從GC Roots到這個物件不可達),則證明此物件不可用,這時候就被判定為可回收的物件。當我們已經知道要回收的物件何時觸發垃圾收集呢?安全點,安全點就是一些讓程式暫定執行從而進行GC的位置,由此我們很容易知道GC停頓的時間是垃圾收集的核心。所有的垃圾收集演算法以及衍生出來的垃圾收集器無不圍繞著盡量減少GC停頓時間產生的,現在最新的G1垃圾收集器可以建立可預測的停頓時間模型,有計劃的避免在整個Java堆中進行全區域的垃圾收集。前文介紹記憶體區域分佈的概念的時候,我們談到了新生代、老年代,而不同的垃圾收集器有可能作用於新生代,也有可能作用於老年代,甚至沒有分代的概念(例如G1收集器),說到這,以下就具體介紹下垃圾收集演算法及對應的垃圾收集器

  標記-清除演算法

  最基礎的收集演算法,演算法分為標記和清除兩個階段:首先標記處所有要回收的對象,在標記完成之後統一回收所有被標記的對象。它最大的不足是效率不高,還會產生大量不連續的內存碎片,這樣導致的問題當程序運行過程分配較大對象時,即使堆中還有足夠的內存,但是無法找到足夠的連續內存只能不得不觸發一次GC操作。這地方對應的垃圾收集器是CMS收集器。

  複製演算法

  複製演算法是為了解決效率問題而生的,它可以將可用記憶體容量劃分為大小相等的兩塊,,每次只使用其中一塊,當這一塊記憶體用完了,就將還存活的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣每次會對整個半區進行GC,並且不會產生記憶體碎片等問題。現在的商業虛擬機器大多採用這種演算法來回收新生代,另外分割記憶體比例也不是1:1,像HotSpot預設Eden(一塊Eden區)和Survivor(兩塊Survivor區)的大小比例為8:1,每次使用Eden和其中一塊Surviovr區,也就是新生代中可用記憶體空間是整個新生代的90%,當回收時,將Eden和其中一塊Survivor中還存活的物件一次性複製到另一塊Survivor中,最後清理掉Eden和剛才用到的Survivor空間,細心的讀者在這地方也許會有發現,如果複製過程那塊沒用的Survivor不夠用怎麼辦呢?這時候需要依賴老年代進行分配擔保,擔保成功就會將Eden和其中一塊Survivor中還存活的對象移動到老年代中,擔保失敗就不得不在老年代觸發一次垃圾回收。這裡延伸一下,新生代垃圾回收稱為Minor GC,因為Java物件大多朝生夕死的特性,所以Minor GC很頻繁,一般回收速度也快,而老年代垃圾回收稱為Major GC/Full GC,Major GC的速度一般會比Minor GC的速度慢很多,從前面的分析過程我們可以輕易的推斷,出現了Major GC,經常會伴隨著一次Minor GC,但非絕對,因此我們GC的目的其實也是透過調優盡量控制減少Major GC的頻率。這地方對應的垃圾收集器是Serial收集器、ParNew收集器(Serial收集器多執行緒版本,可與後面談到的老年代收集器CMS進行配合工作)、Parallel Scavenge收集器。

  標記-整理演算法

  這個演算法是應用在老年代垃圾回收的演算法,因為老年代不像複製演算法那樣回收頻率高,另外它還會浪費空間。標記-整理過程與標記-清除差不多,無非後續步驟不是直接對可回收物件進行清除,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。這地方對應的垃圾收集器是Serial Old收集器、Parallel Old收集器。

  分代收集演算法

  目前商業虛擬機都採用這種演算法,它的想法就是我們前面提到的對堆記憶體區域進行分代,新生代和老年代,不同的區域採用不同垃圾收集演算法。新生代用複製演算法,老年代用標記-整理或標記-清除演算法。

 回顧

  前面扯了這麼多,也許大家對一段Java Code的生命史有點概念了,或者說沒怎麼看懂呀,在這地方我們舉個例子回顧下整個流程,當我們new一個物件的時候,會經歷什麼呢?結合前面所說的,JVM遇到一個new指令時,首先去檢查整個指令參數能否在方法區的常數池定位到一個類別的符號引用,並且檢查整個符號引用代表的類別是否已被載入、解析和初始化過,如果沒有,則必須先執行對應類別載入過程。類別載入檢查通過後,接下來JVM將會為新生物件分配內存,這個過程是在堆中進行的,分配大小在類別載入完成後就可以確定,如果堆內存是規整的,則採用指針移動物件大小相等距離即可,這種分配方式叫做“指針碰撞”,如果是零散的,則JVM維護一個列表記錄哪些內存可用,分配並更新列表記錄,這種方式叫“空閒列表”,至於採用哪種方式,取決於我們前面提到的堆採用了哪種垃圾收集器決定的。劃分完物件記憶體之後,虛擬機會進行必要的初始化操作,接下來需要對物件進行必要的設置,這些資訊設定在物件頭(類元資料資訊、物件的雜湊碼、物件的GC分代年齡等等)裡面,這些工作完成之後,一個新物件產生了,這地方其實還沒結束,再下一步就是呼叫()方法進行程式猿計劃的對物件欄位進行的賦值操作,最後設定棧中的引用指向這個堆中物件所在的記憶體位址(直接引用),這時候一個真正可用的物件已經產生了,至於後續對物件進行的各種操作及最後的死亡就是前面提到的字節碼執行引擎啊GC啊相信大家不再陌生。

 

以上是Java程式的生命史的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn