Java程序的生命史

說起一段Java Code,從出生到game over大體分這麼幾步:編譯、類加載、運行、GC.

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

      a.字節碼生成
  字節碼生成是Javac編譯過程的最後一個階段,在這個階段會把前面各步驟生成的信息轉化成字節碼寫到磁盤中,還會進行了少量代碼添加和轉換的工作。實例構造器<init>()方法和類構造器<clinit>()方法(這裏的實例構造器並不是指默認構造函數,如果用戶代碼沒有提供任何構造函數,那編譯器將會添加一個沒有參數的、訪問性與當前類一致的默認構造函數,這個工作在填充符號表階段已經完成,而類構造器<clinit>()方法指的是編譯器自動收集類中的所有類變量賦值動作和靜態語句塊中的語句合併產生的)就是在這個階段添加到語法樹中的。到此爲止整個編譯過程結束。

 2.類加載

加載、驗證、準備、解析、初始化五步。其中加載、驗證、準備、初始化是順序執行的,而解析則不一定,它有可能會在初始化之後執行。


     a.加載

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


      b. 驗證

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


  c.準備
  準備階段正式爲類變量分配內存並設置初始值的階段,這些變量所使用的內存都在方法區進行分配。


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

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

①遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有初始化,則需要觸發其初始化,前面各種叉叉指令什麼鬼,簡單理解就是new一個對象的時候,讀取或者設置一個類的靜態字段的時候,調用一個類的靜態方法的時候。
使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有初始化,則需要觸發其初始化。
②當初始化一個類,發現其父類還沒進行初始化,則先觸發其父類的初始化操作。
③當虛擬機啓動時,用戶需要指定一個要執行的主類(main方法所在類),虛擬機會先初始化這個主類。
④當使用JDK1.7以上的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果爲REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應類沒有進行初始化,則觸發初始化操作。

3.運行

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

      4.GC

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

a.複製算法

  複製算法是爲了解決效率問題而生的,它可以將可用內存容量劃分爲大小相等的兩塊,,每次只使用其中一塊,當這一塊內存用完了,就將還存活的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣每次會對整個半區進行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收集器。

b.分代收集算法

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章