天天看點

【Java技術指南】「編譯器專題」深入分析探究“靜态編譯器”(JAVA\IDEA\ECJ編譯器)

技術分析

  • 大家都知道Eclipse已經實作了自己的編譯器,命名為 Eclipse編譯器for Java (ECJ)。
ECJ 是 Eclipse Compiler for Java 的縮寫,是 JavaTM 認可的 Java 編譯工具(類似 javac)。可以單獨下載下傳使用。
  • IDEA所支援的編譯器,也有幾種:javac(Java原生編譯器)、ECJ(支援使用Eclipse編譯器)、ACJ編譯器(不太清楚),其中預設使用的是Javac,同時也推薦使用Javac。

有興趣可以看看ECJ編譯器的相關使用以及獨立使用ECJ

大家的誤解

首先,很多小夥伴們都跟我說過Javac和JIT還有AOT它們都是什麼有啥差別啊?其實無論是ECJ之類的Java源碼編譯器運作的時候,也都是就是靜态編譯(前端編譯器),而不是JVM裡的JIT(主要面向與優化!),此處之前的文章介紹過JIT和AOT編譯器,是以此處不做過多贅述!

主流的使用方式

  • “主流”Java系統的做法——javac + HotSpot VM的組合就是如此。
  • 運作時虛方法內聯(virtual method inlining)就是這種例子。這樣就可以跨越Class邊界做優化,跟C/C++程式的LTO(link-time optimization)一樣,不過C/C++程式真在運作時做LTO的很少,這方面反而是Java“更勝一籌”…呃,C/C++寫的一個動态連結庫通常也有大量代碼可以放在一起優化,對LTO的需求本來就遠沒有Java高。

靜态編譯階段

首先要确定概念:“編譯期”肯定是指諸如Javac、ECJ之類的Java源碼編譯器運作的時候,也就是靜态編譯;而不是JVM裡的JIT編譯器運作的時候,也就是動态編譯!

動态編譯器之優化!

之前介紹過了逃逸分析屬于動态編譯器情況下對代碼進行相關的逃逸分析優化技術,主要針對于動态編譯時候做的優化,為什麼不可以在靜态編譯器進行優化,這樣性能不會很高嗎?

以WALA為例,有簡單的逃逸分析實作:

例如,方法内逃逸分析:TrivialMethodEscape

javac優化能力分析

  • 你肯定會有一個疑問?那為啥沒見到啥現成的産品在編譯器時做逃逸分析和相關優化,或者為啥javac不做這種優化?
  • 回答:目前Javac幾乎啥優化都不做,優化的操作和能力都交接了JVM(動态編譯器)實作了,并非是技術原因(技術無法實作?),主要是Sun / Oracle的公司壓根就沒有考慮在Javac的時候進行代碼優化操作。

不過即使是這樣,仍然有現成的産品做這種事情啊,隻不過是針對Android,大家可以參考DexGuard。

  • 但是也還是有一些值得注意的優化尚未得到支援:
    • 例如将一些常量值提取到循環之外!
    • 以及一些相關的逃逸分析技術考慮,具體可以參考其相關官方文檔!
  • Java也有靜态編譯優化技術,例如,[Excelsior JET]http://www.tucows.com/preview/371869/Excelsior-JET-For-Windows)比HotSpot VM早得多就實作了逃逸分析及相關優化,而且是靜态編譯時做的而不是運作時(JIT)做的。
  • Excelsior JET是一個AOT(Ahead-of-Time)編譯器和運作時系統。

技術難點在哪裡?

  • 主要就是Java的分離編譯(separate compilation)和動态類加載(dynamic class loading)/動态連結(dynamic linking)。
  • 不知道運作時會加載并連結上什麼代碼,但是具體原因不僅僅是“反射”“運作時位元組碼增強(runtime bytecode instrumentation)”。

Java的标準做法是把每個引用類型編譯為一個單獨的Class檔案,這些Class檔案可以單獨的被重新編譯,在運作時可以單獨的被動态加載。例如說:

// Foo.java
public class Foo {
  public void greet(Bar b) {
    System.out.println("Greetings, " + b.toString());
  }
}
// Bar.java
public class Bar {
  public String toString() {
    return "Bar 0x" + hashCode();
  }
}           
  • 這兩個Java源碼檔案可以單獨編譯,也可以單獨重編譯,生成出Foo.class與Bar.class兩個Class檔案。它們在運作時可以單獨被JVM加載,而且每個ClassLoader執行個體都可以加載一次是以同一個Class檔案可能會在同一個JVM執行個體裡被加載多次并被看作不同的Class。
  • 當在靜态編譯Foo.java時,無法假設運作時真的遇到的Bar實作跟現在看到的Bar.java還是一樣,是以不能跨類型邊界(編譯後變成Class檔案邊界)做優化。
  • 這種問題其實跟C/C++程式通常無法跨越動态連結庫的邊界做優化一樣,隻不過一般的Class檔案内包含的代碼遠比不上一個native的動态連結庫,但是受的優化限制卻一樣,使得對Java程式的靜态分析與優化的收益非常受限。
  • 外加Java的面向對象特性帶來的一些“副作用”:
    • 一個風格良好的面向對象程式通常會有大量很小的方法,方法之間的調用非常多,而且很可能是對虛方法的調用(invokevirtual),Java的非私有執行個體方法預設是虛方法。
    • 一個類與它的派生類必然不會在同一個Class檔案裡,這樣即便一個類的A方法調用該類的B方法,也未必能做有效的分析和優化。
例如:
public class Foo {
  public Object foo() {
    return bar(new Object());
  }
  public Object bar(Object o) {
    return null;
  }
}           

對這個類,我們能不能把Foo.foo()靜态優化,內聯Foo.bar()并消除掉無用的new Object(),最好優化成return null呢?

考慮上動态加載與基于類基礎的多态特性的話,答案是不能:我們不知道會不會在運作時有這麼一個派生類:
public class Bar extends Foo {
  public Object bar(Object o) {
    return o;
  }
}           

被加載進來。假如有:

Foo o = new Bar();
o.foo(); // not null           
那這個foo()顯然不會傳回null。
  • 結合起來看,Java有很多小方法、很多虛方法調用、難以靜态分析。
  • 而逃逸分析恰恰需要在比較大塊的代碼上工作才比較有效:JIT編譯器要能夠看到更多的代碼,以便更準确的判斷對象有沒有逃逸。
  • 隻保守的在小塊代碼上分析的話,很多時候都隻能得到“對象逃逸了”的判斷,就沒啥效果了。
拿上面的Foo / Bar例子說,Foo.foo()如果能内聯Foo.bar()就可以判斷new Object()沒逃逸,那标量替換、消除對象配置設定之類的都可以做;反之,局限在Foo.foo()自身内部的話,就隻能保守判斷new Object()有逃逸,于是啥優化也做不了。

這些特性使得對Java程式做高品質的靜态分析變得異常困難:

  • 運作時各種類都加載進來之後再激進的假設那就是目前已經加載的類就代表了“整個程式”,以“closed world”假設做激進優化,但留下“逃生門在遇到與現有假設沖突的新的類加載時抛棄優化,退回到安全的非優化狀态。
  • 要麼可以抛棄Java的分離編譯+動态加載特性,簡化原始問題 ,這樣就什麼靜态分析和優化都能做了。上面提到的DexGuard、Excelsior JET都走這個路線。

Excelsior JET的實作優化的标準和條件

  • 那樣标榜自己實作了标準Java,但又做很多靜态編譯優化,這又是怎麼回事?
  • 其實Java标準隻是說要整個系統看起來維持動态類加載的表象,并沒有說所有程式都一定要用動态類加載。
  • 假如有一個Java應用,它不關心通過動态連結帶來的靈活性,而是在開發時就可以保證所有用到的類全都能靜态準備好,而且不在運作時“靈活”的實用ClassLoader,那它完全可以找一個能對這種場景優化的Java系統來執行它。
  • Excelsior JET就是針對這樣的場景優化的。使用者在使用JET把Java程式編譯成native code時,可以指定編譯模式是“我聲明我的應用肯定不會用某些動态特性”,JET就會相應的嘗試激進的做靜态全局編譯優化。

動态類加載的Java程式怎麼辦?

Excelsior JET的運作時系統裡其實也包含了一個JIT編譯器,是以真的有動态類加載也的話也不懼,兵來将擋而已。激進的靜态優化可以依賴運作時可以回退到重新JIT編譯來保證安全性。

跟Excelsior JET類似的系統還有一些,最出名的可能是GCJ,不過我覺得它沒Excelsior做得完善。根據GCJ的todo清單,很明顯它還沒實作逃逸分析和相關優化。

國内的話,複旦大學有過一個基于Open64的Java靜态編譯器項目,叫做Opencj。

請參考論文:Opencj: A research Java static compiler based on Open64

它也有做逃逸分析,但隻關注了線程級逃逸來做同步削除的優化,而沒有關注方法級逃逸來做标量替換。

反射和運作時位元組碼增強它們不是主要問題。

反射
Java中,反射隻能用來檢視類的結構資訊,而不能改變類的結構資訊;反射可以讀寫執行個體的狀态,但無法改變執行個體的類型。

怎樣算是可以修改類的結構資訊?

  • 修改類的基類,或修改類實作的接口
  • 添加或删除成員(成員方法或字段都算)
  • 修改現有成員的類型(例如修改成員變量的聲明類型,或者修改成員方法的signature之類)

參數無法靜态确定的反射調用是沒辦法靠靜态分析得知調用目标的。

但這對靜态分析的幹擾程度其實跟普通的虛方法也差不了多少,反正都是目标無法确定,隻能做保守分析;加入啟發算法來猜測的話,普通虛方法比反射可能好猜一些,但也僅限于猜。
運作時位元組碼增強
  • Java程式運作的過程中修改程式邏輯的能力,從Java提供這一功能的方法就可以一窺其目的:這個能力主要不是給普通Java程式使用,而是給profiler / debugger用的。
  • Java運作時位元組碼增強,要麼得用Java agent來使用[java.lang.instrument]包裡的功能,要麼得用JVMTI接口寫C/C++代碼實作個JVM agent;普通的、不使用agent的Java程式是用不了這種功能的。讨論Java程式是否能在某場景下優化的話題,一般沒必要考慮對運作時位元組碼增強的支援。
  • ASM庫也是如此。