Java 內聯類初探

本文要點

  • Valhalla項目正在開發內聯類,使Java程序更好地適應現代硬件
  • 內聯類使開發人員能夠編寫一些類型,其行爲更像Java內置的基元類型
  • 內聯類的實例沒有對象標識,創造了許多優化的可能性
  • 內聯類的出現重新引發了有關Java泛型和類型擦除的爭論
  • 儘管內聯類前途光明,但這個項目仍在推進中,尚未投入生產

在本文中我將介紹內聯類。此功能是之前稱爲“值類型”的演變。對這一功能的探索和研究仍在進行,並且是Valhalla項目中的主要工作流,此前InfoQOracle的Java雜誌已經做過了相關報道。

爲什麼要開發內聯類?

內聯類(inline classes)的目標是讓Java程序更好地適應現代硬件。爲了實現這一目標,需要重新審視Java平臺的一個非常基礎的組成部分,即Java數據值的模型。

從Java最早的版本開始直到今天爲止,Java只有兩種類型的值:基本類型和對象引用。這個模型非常簡單,開發人員很容易理解,但是會帶來性能損失的代價。例如,處理對象數組時涉及不可避免的間接訪問,這可能導致處理器緩存未命中。

許多關心性能的程序員都希望程序處理的數據能更有效地利用內存。更好的佈局意味着更少的間接訪問,進而減少緩存未命中並提升性能。

開發人員感興趣的另一大領域是消除"每個數據組合都需要一個完整的對象標頭"的開銷——也就是"展平"數據的理念。

目前而言,Java堆中的每個對象都有一個元數據標頭以及實際的字段內容。在Hotspot中,此標頭本質上是兩個機器碼——mark和klass。首先是mark,其中包含特定於這個特定對象實例的元數據。


元數據的第二個機器碼是klass,它是指向元數據(存儲在內存的Metaspace區域中)的指針,與同一類的其他所有實例共享。這個klass指針是理解運行時如何實現某些語言功能(例如虛擬方法查找)的關鍵所在。

但針對本文所討論的內聯類來說,mark中包含的數據特別重要,因爲它與Java對象的標識概念緊密相聯。

內聯類和對象標識

回想一下,在Java中,兩個對象實例並不會只因爲它們的所有字段都有相同的值,就被認爲是相等的。Java使用==運算符來確定兩個引用是否指向相同的內存位置,如果對象分別存儲在內存中的不同位置,則它們不會被視爲相同的。

注意:這個標識概念與鎖定Java對象的能力相關。實際上mark是用來存儲對象監視器(以及其他內容)的。

但對於內聯類,我們希望組合具有實質上是基本類型的語義。在這種情況下,判斷相等與否時唯一重要的是數據的位模式,而不是該模式在內存中出現的位置。

因此,移除對象標頭後我們還移除了組合的唯一標識。這一更改釋放了運行時,從而在佈局、調用約定、編譯和調度層面帶來顯著的優化。

注意:移除對象標頭還對內聯類的設計帶來了其他影響。例如它們無法同步(因爲它們既沒有唯一標識,也沒有存儲監視器的位置)。

我們需要意識到,Valhalla是一個貫穿語言和VM直達核心的項目。這意味着對於程序員來說,它可能看起來就像一個新的構造(inline class),但這個功能依賴的層有很多。

注意:內聯類與即將發佈的記錄功能不同。Java記錄只是用減少的樣板聲明的常規類,並且具有一些標準化的,由編譯器生成的方法。相比之下,內聯類本質上是JVM中的一個新概念,它從根本上改變了Java的內存模型。

當前的內聯類原型(稱爲LW2)已經可以工作了,但仍處於非常非常早期的階段。它的目標受衆是高級開發人員、庫作者和工具開發商。

使用LW2原型

下面我們來深入研究一下內聯類當前的LW2狀態,看看用它可以做些什麼事情。我會用一些底層技術(例如字節碼和堆直方圖)展示內聯類的效果。未來的原型將添加更多用戶可見和層次更高的事物,但是它們尚未完成,所以我現在只能在底層探索。

要獲得支持LW2的OpenJDK構建,最簡單的方法是在此處下載它——Linux、Windows和Mac構建都可用。另外,經驗豐富的開源開發人員可以從頭開始構建自己的二進制文件。

原型下載並安裝完成後,我們就可以用它來開發一些內聯類。

要在LW2中創建內聯類,請使用inline關鍵字標記類聲明。

內聯類的規則(目前的版本,其中一些規則可能會在將來的原型中放寬或更改):

  • 接口、註釋類型和枚舉不能是內聯類
  • 頂級、內部、嵌套和本地類可以是內聯類
  • 內聯類不可爲空,需要有默認值
  • 內聯類可以聲明內部、嵌套和本地類型
  • 內聯類是隱式final的,因此不能是abstract的
  • 內聯類隱式擴展java.lang.Object(例如枚舉、註釋和接口)
  • 內聯類可以顯式實現常規接口
  • 內聯類的所有實例字段都是隱式final的
  • 內聯類不能聲明其自身類型的實例字段
  • javac自動生成hashCode()、equals()和toString()
  • javac不允許對內聯類使用clone()、finalize()、wait()或notify()

下面看一下我們的第一個內聯類示例,看看像Optional這樣的類型的實現作爲內聯類長什麼樣。爲了少走彎路並簡化演示,我們將編寫一個包含基本值的可選類型的版本,類似於標準JDK類庫中的java.util.OptionalInt類型:

public inline class OptionalInt {
    private boolean isPresent;
    private int v;

    private OptionalInt(int val) {
        v = val;
        isPresent = true;
    }

    public static OptionalInt empty() {
        // New semantics for inline classes
        return OptionalInt.default;
    }

    public static OptionalInt of(int val) {
        return new OptionalInt(val);
    }

    public int getAsInt() {
        if (!isPresent)
            throw new NoSuchElementException("No value present");
        return v;
    }

    public boolean isPresent() {
        return isPresent;
    }

    public void ifPresent(IntConsumer consumer) {
        if (isPresent)
            consumer.accept(v);
    }

    public int orElse(int other) {
        return isPresent ? v : other;
    }

    @Override
    public String toString() {
        return isPresent
                ? String.format("OptionalInt[%s]", v)
                : "OptionalInt.empty";
    }
}

這裏應該使用(當前)LW2版本的javac編譯。要查看新的內聯類技術的效果,我們需要使用javap工具查看字節碼,調用javap的方法如下:

$ javap -c -p infoq/OptionalInt.class

把OptionalInt類型拆開來看,我們就能在字節碼中看到內聯類的一些有趣特性:

public final value class infoq
.
OptionalInt {
  private final boolean isPresent;

  private final int v;

這個類具有一個新的修飾符值,這個值是從較早的原型(當時該功能仍稱爲值類型)中遺留下來的。即使未在源代碼中指定,這個類和所有實例字段也都已定型。接下來讓我們看一下對象構造方法:

public static infoq.OptionalInt empty();
    Code:
       0: defaultvalue  #1                  // class infoq/OptionalInt
       3: areturn

  public static infoq.OptionalInt of(int);
    Code:
       0: iload_0
       1: invokestatic  #11                 // Method "<init>":(I)Qinfoq/OptionalInt;
       4: areturn

  private static infoq.OptionalInt infoq.OptionalInt(int);
    Code:
       0: defaultvalue  #1                  // class infoq/OptionalInt
       3: astore_1
       4: iload_0
       5: aload_1
       6: swap
       7: withfield     #3                  // Field v:I
      10: astore_1
      11: iconst_1
      12: aload_1
      13: swap
      14: withfield     #7                  // Field isPresent:Z
      17: astore_1
      18: aload_1
      19: areturn

對於常規類,我們會看到一個已編譯構造序列,就像下面這個簡單工廠方法這樣:

  // Regular object class
  public static infoq.OptionalInt of(int);
    Code:
       0: new           #5  // class infoq/OptionalInt
       3: dup
       4: iload_0
       5: invokespecial #6  // Method "<init>":(I)V
       8: areturn

這兩個字節碼序列之間的區別很明顯——內聯類不使用新的操作碼。相反,我們遇到了兩個專門針對內聯類的全新字節碼——defaultvalue和withfield。

  • defaultvalue用於創建新的值實例
  • 使用withfield代替setfield

注意:這種設計帶來的影響之一是,對於每個內聯類,默認值的結果必須是該類型的一致且可用的值。

值得注意的是,withfield的語義是將堆棧頂部的值實例替換爲帶有更新字段的修改過的值。這與setfield(在堆棧上使用對象引用)略有不同,因爲內聯類始終是不可變的,不一定總是表示爲引用。



最後再觀察字節碼,我們注意到在這個類的其他方法中有自動生成的hashCode()和equals()的實現,它們使用invokedynamic作爲一種機制。

public final int hashCode();
    Code:
       0: aload_0
       1: invokedynamic #46,  0             // InvokeDynamic #0:hashCode:(Qinfoq/OptionalInt;)I
       6: ireturn

  public final boolean equals(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: invokedynamic #50,  0             // InvokeDynamic #0:equals:(Qinfoq/OptionalInt;Ljava/lang/Object;)Z
       7: ireturn

在我們的例子中,我們顯式提供了toString()的重寫,但是通常也會爲內聯類自動生成此這一方法。

public java.lang.String toString();
    Code:
       0: aload_0
       1: getfield      #7                  // Field isPresent:Z
       4: ifeq          29
       7: ldc           #28                 // String OptionalInt[%s]
       9: iconst_1
      10: anewarray     #30                 // class java/lang/Object
      13: dup
      14: iconst_0
      15: aload_0
      16: getfield      #3                  // Field v:I
      19: invokestatic  #32                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      22: aastore
      23: invokestatic  #38                 // Method java/lang/String.format:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
      26: goto          31
      29: ldc           #44                 // String OptionalInt.empty
      31: areturn

爲了驅動我們的內聯類,讓我們看一下Main.java中包含的一個小型驅動程序:

public static void main(String[] args) {
        int MAX = 100_000_000;
        OptionalInt[] opts = new OptionalInt[MAX];
        for (int i=0; i < MAX; i++) {
            opts[i] = OptionalInt.of(i);
            opts[++i] = OptionalInt.empty();
        }
        long total = 0;
        for (int i=0; i < MAX; i++) {
            OptionalInt oi = opts[i];
            total += oi.orElse(0);
        }
        try {
            Thread.sleep(60_000);
        } catch (Exception e) {
            e.printStackTrace();
        }

        System.out.println("Total: "+ total);
    }

這裏沒有展示Main的字節碼,因爲它沒有任何特別之處。實際上,如果Main使用java.util.OptionalInt替代我們的內聯類版本,生成的代碼也是一樣的(除了包名以外)。
當然這樣做的一部分原因是讓內聯類對主流Java程序員的影響儘量減小,並在不增加開發人員認知負擔的前提下提供所有好處。

內聯類的堆行爲

注意到編譯值類的字節碼的功能之後,我們現在可以執行Main並快速瀏覽一遍運行時行爲,從堆的內容開始。

$ java infoq.Main

注意,程序末尾的線程延遲只是爲了讓我們有時間從進程中生成堆直方圖。
爲此,我們在單獨的窗口中運行另一個工具:jmap -histo:live ,它會生成如下結果:

 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:             1      800000016  [Qinfoq.OptionalInt;
   2:          1687          97048  [B (java.base@14-internal)
   3:           543          70448  java.lang.Class (java.base@14-internal)
   4:          1619          51808  java.util.HashMap$Node (java.base@14-internal)
   5:           452          44600  [Ljava.lang.Object; (java.base@14-internal)
   6:          1603          38472  java.lang.String (java.base@14-internal)
   7:             9          33632  [C (java.base@14-internal)

這表明我們已經分配了一個單一的infoq.OptionalInt值數組,它大約佔用了8億空間(1億個元素,每個元素大小爲8)。


不出所料,我們的內聯類沒有獨立的實例。

注意:熟悉Java類型描述符的內部語法的讀者可能會注意到新的Q類型描述符,它用來表示內聯類的值。

爲了方便對比,我們來使用java.util中OptionalInt的版本代替內聯類版本重新編譯Main。現在直方圖看起來完全不一樣了(Java 8的輸出):

num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:      50000001     1200000024  java.util.OptionalInt
   2:             1      400000016  [Ljava.util.OptionalInt;
   3:          1719          98600  [B
   4:           540          65400  java.lang.Class
   5:          1634          52288  java.util.HashMap$Node
   6:           446          42840  [Ljava.lang.Object;
   7:          1636          39264  java.lang.String

現在,我們有一個單一數組,其中包含1億個大小爲4的元素,這些元素是對對象類型java.util.OptionalInt的引用。我們還有5,000萬個OptionalInt實例,再加上一個空值實例,這樣非內聯類實例的總內存佔用約爲1.6G。


這意味着在這種極端情況下,使用內聯類可將內存開銷減少約50%。這就是"codes like a class,works like an int”這條原則的一個很好的實例。

使用JMH進行基準測試

下面來看一個簡單的JMH基準測試。這是爲了從減少程序運行時間的角度,評估減少間接尋址和高速緩存未命中的效果。

可以在OpenJDK網站上找到有關設置和運行JMH基準的詳細信息。

我們的基準測試將直接對比OptionalInt的內聯實現和JDK中的版本。

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public class MyBenchmark {

    @Benchmark
    public long timeInlineOptionalInt() {
        int MAX = 100_000_000;
        infoq.OptionalInt[] opts = new infoq
.
OptionalInt[MAX];
        for (int i=0; i < MAX; i++) {
            opts[i] = infoq.OptionalInt.of(i);
            opts[++i] = infoq.OptionalInt.empty();
        }
        long total = 0;
        for (int i=0; i < MAX; i++) {
            infoq.OptionalInt oi = opts[i];
            total += oi.orElse(0);
        }

        return total;
    }

    @Benchmark
    public long timeJavaUtilOptionalInt() {
        int MAX = 100_000_000;
        java.util.OptionalInt[] opts = new java
.
util
.
OptionalInt[MAX];
        for (int i=0; i < MAX; i++) {
            opts[i] = java.util.OptionalInt.of(i);
            opts[++i] = java.util.OptionalInt.empty();
        }
        long total = 0;
        for (int i=0; i < MAX; i++) {
            java.util.OptionalInt oi = opts[i];
            total += oi.orElse(0);
        }

        return total;
    }
}

在最新的高配MacBook Pro上運行一次測試即可得到以下結果:

Benchmark                             Mode  Cnt  Score   Error  Units
MyBenchmark.timeInlineOptionalInt    thrpt   25  5.155 ± 0.057  ops/s
MyBenchmark.timeJavaUtilOptionalInt  thrpt   25  0.589 ± 0.029  ops/s

這表明在這種特定場景中內聯類要快得多。但要記住不應該太過拔高這一示例的意義,這只是爲了演示而舉的例子。
正如JMH框架本身給出的警告所言:“不要因爲你想看到什麼樣的結果,就假設數字會告訴你怎樣的結果。”

例如,在這個例子中infoq.OptionalInt的測試版本大約分配了50%——是因爲分配的減少導致了性能提升?還是還有其他性能影響?只看這個基準測試並不能得出結論——它僅僅是一個數據點而已。

這個粗略的基準測試結果只能表明內聯類在某些精心選擇的場景下可能體現出顯著的加速效,除此之外不應該特別看重這個結果或將其用於其他用途。

例如,LW2原型僅支持解釋模式和C2(服務器)JIT編譯器。沒有C1(客戶端)編譯器,沒有分層編譯,也沒有Graal。此外,解釋器尚未優化,因爲重心都放在了JIT實現上。預期所有這些功能都將在Java的發行版本中提供,而如果沒有它們,所有的性能數字都會是完全不可靠的。

實際上,就當前的LW2預覽版來說,除了性能外還有很多工作要做。很多基礎問題尚待解決,例如:

  • 如何擴展泛型以支持對所有類型抽象,包括基元、值甚至void這些類型?
  • 內聯類真正的繼承層次結構應該是什麼樣的?
  • 類型擦除和向後兼容性的問題該怎麼辦?
  • 如何使現有庫(特別是JDK)保持兼容的同時進化以充分利用內聯類?
  • 現有的LW2約束能夠,或者應該放寬多少?

大多數問題仍未解決,但LW2試圖提供在其中一個領域提供答案,就是爲內聯類設計一種原型機制,使其可以在通用類型中被用作類型參數(“有效負載”)。

內聯類作爲類型參數

在當前的LW2原型中我們必須克服一個問題,那就是Java的泛型模型隱式地假定了值的可空性,而內聯類是不可空的。

爲了解決這個問題,LW2使用了一種稱爲間接投影的技術。這就像爲內聯類設計的一種自動裝箱形式,允許我們對於任何內聯類型Foo編寫Foo?類型。

最終結果是,間接投影類型可以用作通用類型中的參數(而真正的內聯類型則不能),如下所示:

   public static void main(String[] args) {
        List<OptionalInt?> opts = new ArrayList<>();
        for (int i=0; i < 5; i++) {
            opts.add(OptionalInt.of(i));
            opts.add(OptionalInt.empty());
            opts.add(null);
        }
        int total = opts.stream()
                        .mapToInt(o -> {
                            if (o == null) return 0;
                            OptionalInt op = (OptionalInt)o;
                            return op.orElse(0);
                        })
                        .reduce(0, (x, y) -> x + y);

        System.out.println("Total: "+ total);
    }

內聯類的實例始終可以強制轉換爲間接投影的實例,但反之則需進行空檢查,如示例中的lambda正文所示。

注意:間接投影的使用仍處於實驗階段。內聯類的最終版本可能使用完全不同的設計。

在內聯類真正準備好成爲Java語言中的功能之前仍有大量工作要做。像LW2這樣的原型對於感興趣的開發人員來說是很有趣的嘗試,但應該牢記這些只是一種智力活動。當前版本中的任何內容都不一定是這個功能最終採用的形式。

作者介紹

Ben Evans是JVM性能優化公司jClarity的聯合創始人。他是LJC(倫敦的JUG)的組織者和JCP執行委員會成員,幫助定義Java生態系統的標準。Ben是Java冠軍、3次JavaOne Rockstar發言人;他是《全能Java開發人員》和《Java簡介》《優化Java》新版的作者。他定期爲Java平臺、性能、體系結構、併發性、創業企業等相關主題發表演說。Ben有時可以提供演講、教學、寫作和諮詢服務——請聯繫他獲取詳細信息。

原文鏈接

A First Look at Java Inline Classes

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