☕【Java技術指南】「編譯器專題」深入分析探究“靜態編譯器”(JAVA\IDEA\ECJ編譯器)是否可以實現程式碼優化?

浩宇天尚 發表於 2021-10-14

技術分析

  • 大家都知道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程式是否能在某場景下優化的話題,一般沒必要考慮對執行時位元組碼增強的支援。

即便要支援,主流JVM通過JIT編譯器可以重複多次優化編譯程式碼,優化的程式碼可以被拋棄退回到非優化形式執行,從而既可以激進的做優化、又可以安全的支援這些動態功能;像Excelsior JET這種主要以AOT方式編譯Java程式碼的,為了能提供完善的Java支援還是可選在執行時帶有JIT編譯器。

  • Javassist,這就是典型的執行時Java位元組碼增強的應用。

  • ASM庫也是如此。

位元組碼增強也可以在執行之前做,通常叫做“weaving”。所有在執行之前對位元組碼做的修改都應該看作籠統的“編譯時”的一部分——如果用javac編譯也是你指定的,接著用啥post weaving也是你指定的,那你不能怪javac不知道後面還會有程式修改位元組碼,而應該把javac和post weaver看作達成你的位元組碼生成目的的整體看作一個邏輯上編譯系統。