JDK 22 功能:JEP 454 新世代原生程式碼整合之外部函式與記憶體 API

JEP 454: Foreign Function & Memory API

為了加快執行速度和使用既有的外部函式庫以避免重複造輪子,Java 程式常會需要與原生程式碼(Native Code)進行互動。它的目的是為了效能優化、存取硬體資源,或是使用現有的功能強大 C/C++ 函式庫。然而,傳統的 Java Native Interface(JNI)機制存在著許多限制和安全性問題。

Java 22 推出的 JEP 454 Foreign Function & Memory API(FFM API)提供了全新的解決方案。JEP 454 不僅提供了更安全、更現代化的方式來進行 Java 與原生程式碼的互動,還大幅簡化了記憶體管理和函式呼叫的複雜度。下面老喬會簡介如何使用 JEP 454 FFM API 來達成更優秀且更健全的外部存取方式。

前言

Java 平台為開發人員提供了豐富的解決方案,包括與其他平台函式庫互動的功能。無論是存取資料庫(JDBC)、使用 Web 服務(HTTP 客戶端)、服務遠端客戶(NIO channels),或是本地行程通訊(Unix-domain socket),Java API 可以方便且可靠地存取非 Java 語言撰寫的資源。

遺憾的是,有時當我們需要與原生程式碼互動時,仍會面臨到重大的技術障礙。

為何需要新的原生程式碼整合方案

作為早期的標準解決方案,JNI 需要我們同時維護不同語言的程式碼,並且要生成繁瑣的標頭檔案和處理同步問題。這不僅增加了開發複雜度,也容易導致程式碼品質的下降。

更嚴重的是,在處理堆外記憶體(off-heap memory)時,現有的解決方案都存在著明顯的侷限。此外,在著重效能的場景中,如 TensorflowIgniteLuceneNetty 等框架,垃圾回收的不可預測性常常成為棘手的問題。這些框架需要更精確的記憶體控制機制,以確保穩定的效能表現。

外部記憶體

使用 new 關鍵字建立的物件會儲存在 JVM 的堆積(heap)中,並且在不需要時被垃圾收集器清除。然而,對於性能關鍵的函式庫,垃圾收集的成本和不可預測性是無法接受的。它們需要將資料儲存在堆外記憶體中以自行分配和釋放,或是將檔案直接映射到記憶體中(例如,mmap)做序列化和反序列化資料。

Java 平台提供兩種存取堆外記憶體的 API:

  • ByteBuffer API 提供直接位元組緩衝區,它支援固定大小的堆外記憶體。每個記憶體空間的最大上限是 2GB,其功能非常陽春,並且讀寫時容易出錯。更嚴重的是,只有當 ByteBuffer 物件被回收時,其記憶體空間才會被釋放,而我們無法控制這一點。
  • sun.misc.Unsafe API 提供方法去低階存取堆內記憶體(on-heap memory),也適用於堆外記憶體。Unsafe 的速度很快(因為它的記憶體存取操作是 JVM 的內部指令)、允許巨大的堆外記憶體空間(理論上高達 16EB)、並且提供釋放記憶體的細粒度控制(因為 Unsafe::freeMemory 可以隨時呼叫)。雖然它具有彈性,但因為有太多的控制權,導致使用它時過於危險,並容易造成記憶體洩漏或系統崩潰。

在長期運行的程式中,函式庫會隨著時間與多個不同的堆外記憶體空間互動。某個記憶體空間中的資料可以指向另一個記憶體空間中的資料,並且數個記憶體空間必須以正確的順序被釋放,否則過程中產生的懸空指標(dangling pointer)將導致釋放後使用(use-after-free)的錯誤。

同樣的狀況也適用於 JDK 之外的 API。它們通過包裝去呼叫 mallocfree 的原生程式碼來提供細粒度的分配和釋放。

因此,經驗豐富的開發人員應該有一組 API,能夠像堆內記憶體一樣安全地分配、操作和共享堆外記憶體。這樣的 API 應該在可預測的釋放和防止過早釋放的需求之間取得平衡,因為過早釋放可能導致 JVM 崩潰,或者更糟的是導致記憶體損壞。

外部函式

雖然 JNI 自 Java 1.1 以來一直支援呼叫原生程式碼,但由於許多的因素,它並不夠用。

  • JNI 涉及幾個繁瑣的組件:Java API(原生方法)、從 Java API 衍生出來的 C 標頭檔,以及呼叫相關原生函式庫的 C 語言實作。我們必須橫跨多個工具鏈,並確保它們之間同步。這對原生函式庫的開發造成了實作上的困難與麻煩。
  • JNI 只能與使用作業系統與 CPU 呼叫慣例的函式庫互通,這些函式庫通常是以 C 或 C++ 編寫的。如果某個函式是使用不同的呼叫慣例所撰寫,那麼原生方法將無法直接呼叫該函式。
  • JNI 無法協調 Java 型別與 C 型別。Java 程式碼使用物件來表示聚合資料,而 C 程式碼則使用結構,因此任何傳遞給原生方法的 Java 物件都必須由原生程式碼辛苦地拆解。舉例來說,將 Java 記錄類別 Person 的物件傳遞給 native 方法時,原生程式碼需要使用 JNI 的 C API 從物件中擷取欄位(例如 firstNamelastName)。
    • 因此,Java 開發人員有時會將資料壓扁成單一物件,例如 byte 陣列或直接位元組緩衝區。但更常見的情況是,由於透過 JNI 傳遞 Java 物件的速度很慢,所以他們會使用 Unsafe 來配置堆外記憶體,並將其地址以 long 型態傳遞給 native 方法,這使得 Java 程式碼變得非常不安全!

多年來,出現了許多框架來填補 JNI 留下的空白,包括 JNAJNRJavaCPP。它們雖然明顯地改善了 JNI,但情況仍不理想,特別是與其他提供一流的原生交互操作性的語言相比。例如 Python 的 ctypes 套件可以在沒有任何粘合代碼的情況下動態包裝原生函式庫中的函式,而 Rust 則提供了工具可以從 C/C++ 標頭檔裡機械地衍生出原生包裝器。

所以,Java 開發人員應該要有一個受到官方支援的 API,能夠直接使用任何對特定任務有幫助的原生函式庫,而無需透過 JNI 那種繁瑣且笨重的黏合劑。兩個可以作為基礎的絕佳抽象概念是:

  • method handles(方法句柄,或稱為方法控制代碼):對方法類實體的直接引用
  • variable handles(變數句柄,或稱為變數控制代碼):對變數類實體的直接引用

利用方法句柄暴露原生程式碼,以及利用變數句柄暴露原生資料,將會大幅簡化撰寫、建置和發佈的任務。此外,一個能夠對外部函式(即原生程式碼)和外部記憶體(即堆外資料)建模的 API,將為第三方原生交互操作框架提供堅實的基礎。

JEP 454 概觀

JEP 454 建立新的 API,使 Java 程式能夠與外部原生程式碼和資料進行交互操作、能夠呼叫外部函式(即 JVM 之外的程式碼),並安全地存取外部記憶體(即不受 JVM 管理的記憶體),而不會有 JNI 的脆弱性和危險性。

  • 提供一個安全且現代化的方式,讓 Java 應用程式能與原生程式碼互動
  • 取代脆弱的原生方法和 JNI 機制,成為原生程式碼整合的首選解決方案
  • 提供精確的記憶體管理機制,允許 Java 程式安全地分配、存取和釋放堆外記憶體
  • 提供外部函式存取機制,讓 Java 應用程式能夠直接從原生函式庫中呼叫外部函式

優點

  • 提供更強大的安全性保證,並防止程式因不當的記憶體存取或函式呼叫而崩潰
  • 效能與舊有方法一致甚至更好,可提供記憶體和函式的低階存取,並同時保持了相對較高的抽象層次
  • 簡化開發流程,減少樣板程式碼
  • 提供可預測的記憶體釋放機制
  • 支援多種平台的呼叫約定

缺點

  • 雖然已經比 JNI 更易於使用,但因為涉及到與原生程式碼和記憶體的互動,本身就具有一定程度的複雜度
  • 對於不熟悉原生程式設計概念的開發人員來說,學習曲線較陡,且需要理解底層概念
  • 某些操作需要額外的安全性配置

介紹

JEP 454 外部函式與記憶體 API(Foreign Function and Memory API,以下簡稱 FFM API)定義了類別和介面,以便函式庫和應用程式可以:

JEP 454 FFM API 位於 java.base 模組的 java.lang.foreign 套件中。

簡短範例

下方用一個簡短的 JEP 454 範例介紹如何取得一個 C 函式庫中函式 radixsort 的方法句柄,然後使用它對 Java 陣列中的四個字串進行排序(本範例省略了一些細節):

// 1. 在 C 函式庫路徑上尋找外部函式
Linker linker          = Linker.nativeLinker();
SymbolLookup stdlib    = linker.defaultLookup();
MethodHandle radixsort = linker.downcallHandle(stdlib.find("radixsort"), ...);

// 2. 分配堆內記憶體以儲存四個字串
String[] javaStrings = { "mouse", "cat", "dog", "car" };

// 3. 使用 try-with-resources 來管理堆外記憶體的生命週期
try (Arena offHeap = Arena.ofConfined()) {
    // 4. 分配一個堆外記憶體空間來儲存四個指標
    MemorySegment pointers
        = offHeap.allocate(ValueLayout.ADDRESS, javaStrings.length);
 
   // 5. 將字串從堆內複製到堆外
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = offHeap.allocateFrom(javaStrings[i]);
        pointers.setAtIndex(ValueLayout.ADDRESS, i, cString);
    }

    // 6. 呼叫外部函式對堆外數據進行排序
    radixsort.invoke(pointers, javaStrings.length, MemorySegment.NULL, '\\0');
 
   // 7. 將(重新排序的)字串從堆外複製到堆內
    for (int i = 0; i < javaStrings.length; i++) {
        MemorySegment cString = pointers.getAtIndex(ValueLayout.ADDRESS, i);
        javaStrings[i] = cString.reinterpret(...).getString(0);
    }
}

// 8. 所有堆外記憶體都在這裡被釋放
assert Arrays.equals(javaStrings,
                     new String[] {"car", "cat", "dog", "mouse"});  // true

這段程式碼比任何 JNI 的解決方案都清晰得多,因為原本隱藏在原生方法呼叫背後的隱式轉換和記憶體存取都直接在 Java 程式碼中明確呈現。我們也可以使用現代 Java 的慣用語法,例如用串流開啟多個執行緒去並行地複製堆內和堆外記憶體之間的資料。

記憶體區段(Memory Segment)

記憶體區段memory segment)是一個由連續的記憶體空間支援的抽象概念,可以位於堆外或堆內。它可以是:

  • 原生區段(native segment):從堆外記憶體中全新配置(類似於使用 malloc 一樣)
  • 映射區段(mapped segment):包裝於一個映射的堆外記憶體中(類似於使用 mmap 一樣)
  • 陣列或緩衝區段(array or buffer segment):分別包裝於與現有 Java 陣列或位元組緩衝區相關聯的堆內記憶體

所有記憶體區段都提供空間和時間界限,以確保記憶體存取操作是安全的。

區段的空間界限

空間界限決定了與區段關聯的記憶體位址範圍,並保證不會使用未分配的記憶體。例如下面的代碼分配了一個 100 位元組的原生區段,因此相關的位址範圍是從某個基址 bb + 99(含):

MemorySegment data = Arena.global().allocate(100);

區段的時間界限

時間界限決定了區段的生命週期,意即支援該區段的記憶體從配置到釋放之間的這段時間。FFM API 保證記憶體區段在被釋放後無法被存取,不會出現釋放後使用(use-after-free)的情況。

記憶體競技場(Arena)

時間界限主要由分配該區段的記憶體競技場Arena,或稱為記憶體管理區)所決定。

同一個競技場所分配的多個區段具有同樣的時間界限,並且可以安全地相互參考:區段 A 可以持有指向區段 B 中某個地址的指標,區段 B 也可以持有指向區段 A 中某個地址的指標。當競技場釋放時兩個區段亦會同時被釋放,這樣兩個區段都不會有懸空指標(dangling pointer)。

全域競技場

最簡單的競技場是全域競技場Global arena),它提供無限的生命週期:意即永遠存活。其中的區段(如上例程式碼所示)始終可以被存取,並且支援該區段的記憶體將永遠不會被釋放,直到 JVM 結束。

  • 呼叫 Arena.global() 取得
  • 生命週期與 JVM 相同,只有在 JVM 關閉時才會釋放資源
  • 適合需要長期存活的記憶體資源

然而,大多數的場景都需要在程式運行時釋放堆外記憶體,因此需要具有有限生命週期的記憶體區段。

自動競技場

自動競技場Automatic arena)提供有限的生命週期:由它分配的區段可以被持續被存取,直到 JVM 的垃圾收集器檢測到該記憶體區段不可使用,此時支援該區段的記憶體空間將被釋放。例如,下述方法在自動競技場中分配一個區段:

void processData() {
    MemorySegment data = Arena.ofAuto().allocate(100);
    // ... 使用 'data' 變數 ...
    // ... 再次使用 'data' 變數 ...
}  // 支援 'data' 區段的記憶體空間在這裡(或之後)被釋放

只要 data 變數不被洩漏出該方法,則該區段最終將被檢測為不可達,使得其支援的記憶體空間將被釋放。

  • 呼叫 Arena.ofAuto() 建立
  • 會被垃圾收集器自動回收
  • 適合簡單用途,不需要精確控制記憶體釋放時機

自動競技場的生命週期是有限但非確定性的。然而,有時候我們需要明確地釋放記憶體。比方說,一個從檔案映射到記憶體區段的 API,應該允許使用者確定性地釋放支援該區段的記憶體空間。因為若是等待垃圾回收器來執行這項工作的話,可能會對效能產生負面影響。

受限競技場

受限競技場Confined arena)提供有限且確定性的生命週期:它從用戶開啟競技場到關閉之前都是存活的。受限競技場分配的記憶體區段僅能在關閉之前存取;一旦它被關閉,支援該區段的記憶體空間將被釋放。如果在關閉競技場後仍嘗試存取該記憶體空間,將會拋出例外。

下列程式碼會開啟一個受限競技場並分配兩個區段:

MemorySegment input = null, output = null;
try (Arena processing = Arena.ofConfined()) {
    input = processing.allocate(100);
    // ... 在 'input' 中設置數據 ...
    output = processing.allocate(100);
    // ... 將數據從 'input' 處理到 'output' ...
    // ... 從 'output' 計算最終結果並將其儲存在其他地方 ...
}  // 支援這些區段的記憶體空間在這裡被明確地釋放
// 下方會拋出 IllegalStateException。若使用 'output' 的話也是一樣
input.get(ValueLayout.JAVA_BYTE, 0); 

退出 try-with-resources 區域時會關閉競技場,此時由它所分配的所有區段都會被標記為無效,並且支援這些區段的記憶體空間會被釋放。

  • 呼叫 Arena.ofConfined() 建立
  • 綁定於建立它的執行緒,並只能在該執行緒中使用
  • 必須手動關閉(實作 AutoCloseable 介面)
  • 適合需要明確控制生命週期的情況

受限競技場的確定性生命週期是有代價的,意即只有單一執行緒可以存取在受限競技場中分配的記憶體區段。

共享競技場

如果多個執行緒需要存取同一個區段,則可以用共享競技場Shared arena)。共享競技場分配的記憶體區段可以被多個執行緒存取,並且無論是否有存取該記憶體空間,任何執行緒都可以關閉競技場以釋放區段。

關閉競技場會原子地使區段無效,但支援區段的記憶體空間可能不會立即被釋放,因為這需要昂貴的同步操作來檢測和取消區段上待處理的並行存取操作。

  • 呼叫 Arena.ofShared() 建立
  • 可以在多個執行緒中使用
  • 必須手動關閉
  • 適合需要跨執行緒共享記憶體資源的場景

競技場控制哪些執行緒可以在何時存取記憶體區段,以提供強大的時間安全性和可預測的性能模型。

FFM API 提供了不同類型的競技場,並提供了彈性的記憶體資源管理選項。我們可以在存取廣度和釋放及時性之間進行權衡,根據應用需求選擇適合的類型。受限制競技場和共享競技場適合需要明確控制記憶體釋放時機的情況,而全域競技場和自動競技場則分別適用於需要長期存活或簡單用途的情境。

解析記憶體區段(De-referencing Segment)

要從記憶體區段中解析資料,我們需要考慮幾個因素:

  • 要被解析的位元組數量,
  • 進行解析操作時位址的對齊限制,
  • 位元組在記憶體區段中的儲存順序(endianness,大小端序),以及
  • 解析操作時所使用的 Java 型別(intfloat)。

這些特性都包含在 ValueLayout 抽象層中。舉例來說,預先定義的 JAVA_INT 數值佈局(value layout)寬度為 4 個位元組、對齊在 4 位元組邊界、使用原生平台的端序(例如,在 Linux/x64 上是小端序),並且與 Java 型別 int 相關聯。

記憶體區段有簡單的解析方法,用於從記憶體區段讀取值和寫入值。這些方法接受一個數值佈局參數,以用來指定解析操作的屬性。例如,我們可以在記憶體區段中連續的偏移量處寫入 25 個 int 值:

MemorySegment segment
    = Arena.ofAuto().allocate(100,                                   // 區段大小
                              ValueLayout.JAVA_INT.byteAlignment()); // 位元組對齊
for (int i = 0; i < 25; i++) {  // 建立 1, 2, 3, ..., 25 的整數序列
    segment.setAtIndex(ValueLayout.JAVA_INT,
                       i,  // 索引值
                       i + 1);  // 實際寫入的數值
}

記憶體佈局(Memory Layout)與結構化存取

下面的 C 程式碼定義了長度為十的 Point 陣列,其中每個 Point 結構有兩個成員:

struct Point {
    int x;
    int y;
} pts[10];

延續上例,我們可以使用以下的程式碼去分配原生記憶體給該陣列,並初始化十個 Point 結構(假設 sizeof(int) == 4):

MemorySegment segment
    = Arena.ofAuto().allocate(2 * ValueLayout.JAVA_INT.byteSize() * 10, // 區段大小
                              ValueLayout.JAVA_INT.byteAlignment());    // 位元組對齊
for (int i = 0; i < 10; i++) {
    segment.setAtIndex(ValueLayout.JAVA_INT,
                       (i * 2),  // x 的索引值
                       i);  // 實際寫入 x
    segment.setAtIndex(ValueLayout.JAVA_INT,
                       (i * 2) + 1,  // y 的索引值
                       i);  // 實際寫入 y
}

為了減少繁瑣計算(例如上例中的 (i * 2) + 1),我們可以使用更具宣告性的記憶體佈局 MemoryLayout 去描述記憶體區段。由於上例中的每個結構都是一對整數,所以我們可以用「序列佈局」(Sequence layout)去描述陣列,然後其中包含十個「結構佈局」(Struct layout),每個結構佈局都是一對 JAVA_INT 佈局:

SequenceLayout pointsLayout  // 序列佈局
    = MemoryLayout.sequenceLayout(10,  // 包含十個結構
                                  MemoryLayout.structLayout(  // 結構內的佈局
                                      ValueLayout.JAVA_INT.withName("x"),  // x 數值佈局
                                      ValueLayout.JAVA_INT.withName("y")));  // y 數值佈局

從序列佈局中,我們可以獲得變數句柄,它可以在任何具有相同佈局的記憶體區段中讀寫元素。例如,我們希望存取序列結構中名為 x 的成員,那麼可以用「佈局路徑」(Layout path)來獲取此類元素的變數句柄。該路徑會導航到一個結構,然後再到其成員 x

VarHandle xHandle = pointsLayout.varHandle(PathElement.sequenceElement(),
                                           PathElement.groupElement("x"));

相應地,對於成員 y

VarHandle yHandle = pointsLayout.varHandle(PathElement.sequenceElement(),
                                           PathElement.groupElement("y"));

現在,我們可以分配一個「序列結構」佈局的原生區段、用兩個變數句柄去設定每個結構中的成員,並初始化一個包含了十個 Point 結構的陣列。每個句柄接受記憶體區段、區段內序列結構的基址,以及一個索引,用以表示設置序列結構中的第幾個結構的成員。

MemorySegment segment = Arena.ofAuto().allocate(pointsLayout);
for (int i = 0; i < pointsLayout.elementCount(); i++) {
    xHandle.set(segment,
                0L,  // 位移量
                (long) i,  // 索引值
                i);  // 實際寫入 x
    yHandle.set(segment,
                0L,  // 位移量
                (long) i,  // 索引值
                i);  // 實際寫入 y
}

區段分配器(Segment Allocator)

當我們在使用堆外記憶體時,常會面臨到如何分配記憶體的難題。JEP 454 FFM API 建立了區段分配器 SegmentAllocator 的抽象概念,去分配與初始化記憶體區段。為了方便起見,Arena 類別實作了 SegmentAllocator 介面,可以用來分配原生區段。換句話說,Arena 是靈活分配和及時釋放堆外記憶體的「一站式服務」:

try (Arena offHeap = Arena.ofConfined()) {
    MemorySegment nativeInt       = offHeap.allocateFrom(ValueLayout.JAVA_INT, 42);
    MemorySegment nativeIntArray  = offHeap.allocateFrom(ValueLayout.JAVA_INT,
                                                      0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
    MemorySegment nativeString    = offHeap.allocateFrom("Hello!");
   ...
}  // 記憶體在這裡被釋放

區段分配器也可以從 SegmentAllocator 介面中的工廠方法來獲得。例如我們可以用工廠方法去建立一個「切片分配器」(Slicing allocator),每當我們需要分配記憶體時,它會從傳入的記憶體區段中擷取片段以滿足需求。下面的程式碼會從現有的記憶體區段中建立一個切片分配器,然後用它來分配新區段並初始化一個 Java 陣列:

MemorySegment segment = ...
SegmentAllocator allocator = SegmentAllocator.slicingAllocator(segment);
for (int i = 0 ; i < 10 ; i++) {
    MemorySegment s = allocator.allocateFrom(ValueLayout.JAVA_INT, 1, 2, 3, 4, 5);
    // ...
}

區段分配器也可以用來建立自定義分配策略的競技場。例如,如果想讓大量的原生區段共享相同的有限生命週期,則自定義競技場可以使用切片分配器來有效地分配這些區段。我們可以同時享受可擴展的分配(歸功於切片)與確定性的釋放(歸功於競技場)。

下例中自定義了一個切片競技場(Slicing arena),其行為類似於受限競技場,但在內部使用切片分配器來回應分配請求。當切片競技場關閉時,底層的受限競技場也會關閉,因此分配的所有區段將會無效。(範例中省略部分程式碼細節)

class SlicingArena implements Arena {
     final Arena arena = Arena.ofConfined();
     final SegmentAllocator slicingAllocator;

     SlicingArena(long size) {
         slicingAllocator = SegmentAllocator.slicingAllocator(arena.allocate(size));
     }

     public void allocate(long byteSize, long byteAlignment) {
         return slicingAllocator.allocate(byteSize, byteAlignment);
    }

     public void close() {
         return arena.close();
     }
}

先前直接使用切片分配器的程式碼現在可以更簡潔:

try (Arena slicingArena = new SlicingArena(1000)) {
     for (int i = 0 ; i < 10 ; i++) {
         MemorySegment s = slicingArena.allocateFrom(ValueLayout.JAVA_INT, 1, 2, 3, 4, 5);
         // ...
     }
}  // 記憶體在這裡被釋放

查詢外部函式

關於外部函式,我們必須要能夠在原生函式庫中先找到給定符號的位址後,才能使用它們。JEP 454 FFM API 支援三種不同類型的符號查詢物件 SymbolLookup

  • SymbolLookup::libraryLookup(String, Arena):用來在使用者指定的原生函式庫中定位所有符號。建立函式庫查詢物件時會載入整個函式庫(例如使用 dlopen()),並與 Arena 物件關聯。而當 Arena 物件關閉時,函式庫會被卸載(例如使用 dlclose()
  • SymbolLookup::loaderLookup():用來在目前的類別載入器中,定位用 System::loadLibrarySystem::load 方法載入的所有原生函式庫中的所有符號
  • Linker::defaultLookup():用來定位與 Linker 實例關聯的原生平台(即作業系統和處理器)上常用函式庫中的所有符號

我們可以利用 SymbolLookup::find(String) 方法來查找外部函式。如果指定的函式名稱存在於符號查詢物件中的話,則該方法會傳回一個零長度的記憶體區段(詳見下面的「零長度的記憶體區段」段落),其基底位址指向該函式的入口點。下方程式碼使用函式庫查詢來載入 OpenGL 函式庫,並找到 glGetString 函式的位址:

try (Arena arena = Arena.ofConfined()) {
    SymbolLookup opengl = SymbolLookup.libraryLookup("libGL.so", arena);
    MemorySegment glVersion = opengl.find("glGetString").get();
    // ...
}  // libGL.so 在這裡被卸載

JEP 454 FFM API 的 SymbolLookup::libraryLookup(String, Arena) 與 JNI 原生函式庫載入機制(即 System::loadLibrary)之間有一項重要的區別:

  • 為了 JNI 而撰寫的原生函式庫(後者)能夠呼叫 JNI 函式去執行 Java 操作,例如建立物件或方法存取,而這些操作會涉及到類別載入。因此,當 JVM 載入這些函式庫時,必須將它們與特定的類別載入器關聯綁定。為了維持類別載入器的完整性,該 JNI 原生函式庫只能從同一個類別載入器所定義的類別中載入。
  • 相較之下,JEP 454 FFM API (前者)不提供原生程式碼存取 Java 函式,也不假設原生函式庫是被設計用於 FFM API。所以 SymbolLookup::libraryLookup 載入的原生函式庫不一定是為了 Java 而編寫,也不會嘗試執行 Java 操作。因此,它們不用和特定的類別載入器綁定,並且可以根據需要而被不同的載入器中多次(重新)載入。

將 Java 程式連結到外部函式

Linker 介面是 Java 程式碼與原生程式碼交互操作的核心。雖然我們經常提到 Java 程式碼和 C 函式庫之間的交互操作,但未來其實也可以支援其他非 Java 的語言。Linker 介面同時支援下行呼叫(downcall,從 Java 端呼叫原生程式碼)和上行呼叫(upcall,原生程式碼呼叫 Java 端):

interface Linker {
    // 下行
    MethodHandle downcallHandle(MemorySegment address,
                                FunctionDescriptor function);
    // 上行
    MemorySegment upcallStub(MethodHandle target,
                          FunctionDescriptor function,
                          Arena arena);
}

下行呼叫使用的 downcallHandle 方法接受一個外部函式位址——通常是從函式庫查詢中獲得的 MemorySegment——並將外部函式公開成下行呼叫的方法句柄。然後 Java 端可以用方法句柄的 invokeinvokeExact 方法去執行外部函式,其中傳遞給 invoke 方法的任何參數都會被透傳給外部函式。

上行呼叫使用的 upcallStub 方法接受一個方法句柄——通常是引用 Java 方法的方法句柄,而不是下行呼叫方法句柄——並將其轉換為 MemorySegment 實例。之後,當 Java 執行下行呼叫的方法句柄時,該記憶體區段作為參數傳遞。實際上,記憶體區段充當為函式指標。(有關上行呼叫請參見下面段落。)

原生連結器(Native Linker)

使用 Linker::nativeLinker() 可以取得原生連結器並連結到 C 函式。它是 Linker 介面的一個實作,符合 JVM 下原生平台的應用程式二進位介面(Application Binary Interface,ABI)。

ABI 指定了呼叫約定,使得某一種語言編寫的程式碼能夠將參數傳遞給另一種語言編寫的程式碼並接收結果。 ABI 還指定了標量 C 型別的長度、位元對齊和大小端序,如何處理可變參數呼叫,以及其他細節。雖然 Linker 介面對於呼叫約定是中立的,但繼承它的原生連結器針對下列平台的呼叫約定進行了優化,並委託 libffi 以支援其他平台的呼叫約定:

  • Linux/x64
  • Linux/AArch64
  • Linux/RISC-V
  • Linux/PPC64
  • Linux/s390
  • macOS/x64
  • macOS/AArch64
  • Windows/x64
  • Windows/AArch64
  • AIX/ppc64

下行呼叫範例

假設我們希望從 Java 程式下行呼叫到標準 C 函式庫中定義的 strlen 函式:

size_t strlen(const char *s);

那麼我們可以用下面的範例取得下行呼叫 strlen 的方法句柄(FunctionDescriptor 的細節在下一段說明):

Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
    linker.defaultLookup().find("strlen").get(),  // 在常用函式庫中查詢
    FunctionDescriptor.of(JAVA_LONG, ADDRESS)
);

呼叫此方法句柄會執行 strlen,並將其結果提供給 Java 程式使用:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment str = arena.allocateFrom("Hello");
    long len          = (long) strlen.invoke(str);    // 回傳長度為 5
}

對於 strlen 的參數,我們使用競技場的 allocateFrom() 將 Java 字串轉換為堆外記憶體區段。然後將此記憶體區段傳遞給 strlen.invoke 時,會以記憶體區段的基底位址作為 char * 參數傳遞給 strlen 函式。

方法句柄非常適合用來暴露(expose)外部函式,因為 JVM 將方法句柄的呼叫機制優化到原生程式碼的層級。當方法句柄指向類別檔案中的方法時,呼叫該方法句柄通常會觸發目標方法的 JIT 編譯。接著,JVM 會解譯 Java 位元碼(Bytecode),透過 MethodHandle::invokeExact 轉交控制權給已產生的組合語言程式碼來執行目標方法。

因此,傳統的 Java 方法句柄在底層實際上是隱含地指向非 Java 的程式碼,而下行呼叫的方法句柄則是這種概念的自然延伸,讓我們明確地指定非 Java 程式碼作為目標。此外,方法句柄還具備簽章多態性的特性,能夠在傳遞基本型別參數時避免裝箱操作以提高效能。總結來說,方法句柄讓 Linker 能夠以自然、高效且可擴展的方式來揭露外部函式。

在 Java 程式碼中描述 C 的型別

JEP 454 建立下行呼叫方法句柄時,原生連結器需要函式描述符 FunctionDescriptor 參數,用來描述目標 C 函式其傳入值和返回值的型別。C 的型別由 MemoryLayout 記憶體佈局物件所描述,主要分為 ValueLayout(用於 intfloat 等標量型別)和 StructLayout(用於結構型別)。與結構型別關聯的記憶體佈局必須是一個複合佈局,它定義了 C 結構中所有欄位的子佈局,包括原生編譯器可能插入的任何與平台相依的填充(padding)。

原生連結器使用 FunctionDescriptor 而得知下行呼叫方法句柄的型別。每個方法句柄都是強型別,代表傳遞給其 invokeExact 方法的參數數量和型別都有嚴格限制。例如,僅接受單一 MemorySegment 參數的方法句柄不能傳入兩個參數 invokeExact(<MemorySegment>, <MemorySegment>),即使該方法允許可變數量的參數。方法句柄的型別描述了呼叫時必須使用的 Java 簽章,它實際上是 C 函式的 Java 級別視圖(view)。

標量型別

如果我們使用標量型別(如 longintsize_t)的 C 函式,則必須要了解原生平台,因為標量型別和預定義數值佈局間的關聯會因為原生平台而有所不同。平台的標量型別和 JAVA_* 數值佈局之間的關聯由 Linker::canonicalLayouts() 揭露。

假設下行呼叫方法句柄要揭露一個接受 C int 並傳回 C long 的 C 函式:

  • 在 Linux/x64 和 macOS/x64 上,C 型別 longint 分別與預定義的佈局 JAVA_LONGJAVA_INT 關聯,因此可以呼叫 FunctionDescriptor.of(JAVA_LONG, JAVA_INT) 來獲得函式描述符。然後,原生連結器將下行呼叫方法句柄的型別安排為 Java 簽章 intlong
  • 在 Windows/x64 上,C 型別 long 與預定義佈局 JAVA_INT 關聯,因此必須使用 FunctionDescriptor.of(JAVA_INT, JAVA_INT) 來獲得函式描述符。然後,原生連結器將下行呼叫方法句柄的型別安排為 Java 簽章 intint

指標型別

針對使用指標的 C 函式,我們不需要了解當前的原生平台,或是當前平台上的指標大小。因為在所有平台上,C 指標型別都與預定義佈局 ADDRESS 關聯,其大小會在執行時確定,所以我們不需要區分 int*char** 的差別。

假設下行呼叫方法句柄要揭露一個接受指標的 void C 函式。由於指標型別與佈局 ADDRESS 關聯,因此可以使用 FunctionDescriptor.ofVoid(ADDRESS) 來獲得函式描述符,接著原生連結器將下行呼叫方法句柄的型別安排為 Java 簽章 MemorySegmentvoid。當 MemorySegment 傳遞給下行呼叫方法句柄時,該區段的基底位址將會皮傳遞給目標 C 函式。

結構型別

與 JNI 不同的是,原生連結器支援將結構化數據傳遞給外部函式。假設下行呼叫方法句柄要揭露一個接受下列結構佈局描述的 void C 函式:

MemoryLayout SYSTEMTIME  = MemoryLayout.ofStruct(
  JAVA_SHORT.withName("wYear"),      JAVA_SHORT.withName("wMonth"),
  JAVA_SHORT.withName("wDayOfWeek"), JAVA_SHORT.withName("wDay"),
  JAVA_SHORT.withName("wHour"),      JAVA_SHORT.withName("wMinute"),
  JAVA_SHORT.withName("wSecond"),    JAVA_SHORT.withName("wMilliseconds")
);

我們可以用 FunctionDescriptor.ofVoid(SYSTEMTIME) 獲得函式描述符,而原生連結器會將下行呼叫方法句柄的型別安排為 Java 簽章 MemorySegmentvoid

呼叫慣例(Calling Convention)

根據原生平台的呼叫慣例,當下行呼叫方法句柄以 MemorySegment 參數執行時,原生連結器會使用函式描述符來決定如何將結構欄位傳給 C 函式。

在某些呼叫慣例中,原生連結器可能會拆解該記憶體區段,讓通用 CPU 暫存器傳遞前四個欄位,並用 C 堆疊傳遞剩下的欄位。而另一種呼叫慣例中,原生連結器可能會間接傳遞結構。也就是說,先配置一塊記憶體區域,將傳入的記憶體區段內容整批複製(Bulk Copy)到該區域,然後將該區域的指標傳遞給 C 函式。這種低階的參數打包(Packaging)過程會在底層自動處理,無需程式介入或監督,可確保與 C 程式碼的高效互通。

如果某個 C 函式用「以值傳回」(By-Value)的方式傳回結構,那麼就必須在堆外分配一個新的記憶體區段,並將其回傳給 Java 端。為了達成這點,由 downcallHandle 傳回的方法句柄需要額外的 SegmentAllocator 參數,讓原生連結器用來分配記憶體區段,以儲存 C 函式所回傳的結構。

雖然原生連結器專注於提供 Java 程式碼和 C 函式庫之間的交互操作性,但上層的 Linker 介面本身是語言中立的。它沒有指明要如何定義任何原生資料型別,因此我們需要負責指定適合各種 C 型別的佈局定義。這其實是故意的,因為 C 型別的佈局定義——無論是簡單的標量還是複雜的結構——最終都依賴於平台。JDK 團隊預計此類佈局將由特定於目標原生平台的工具以機械化地方式產生。

零長度的記憶體區段

外部函式經常會分配一個記憶體空間,並返回一個指向該空間的指標。不過在 Java 中用記憶體區段去建模這類空間具有挑戰性,因為在 Java 執行時無法得知該空間的大小。例如,返回型別為 char* 的 C 函式可能會返回指向單一 char 值的指標,或者以 '\0' 結尾的 char 序列指標。對於呼叫該外部函式的程式碼來說,不容易看出該空間的大小。

JEP 454 FFM API 將外部函式傳回的指標表示為零長度記憶體區段(Zero-Length Memory Segment),該區段的位址就是指標的值,而區段的大小為零。同樣地,當 Java 程式從記憶體區段中讀取指標時,也會返回一個零長度記憶體區段。

零長度區段具有不重要、微不足道的空間界限(Trivial Spatial Bounds),任何嘗試存取此類區段的操作都會拋出 IndexOutOfBoundsException。這是一項至關重要的安全機制:由於不知道這些記憶體區段所對應到的記憶體空間大小,因此無法驗證對該區段的存取操作。實際上,零長度記憶體區段是封裝了位址的物件,不能在沒有明確意圖的情況下使用它。

我們可以呼叫 MemorySegment::reinterpret 將零長度記憶體區段轉換為特定大小的原生區段。它會添加新的空間和時間界限,並允許讀取操作。不過,依此方法返回的記憶體區段是不安全的。例如零長度記憶體區段底層是長度為 10 位元組的記憶體空間,但用戶可能會高估該空間的大小,並獲取 100 位元組長度的區段。此時如果用戶嘗試讀取空間邊界以外的記憶體空間時,可能會導致 JVM 崩潰。

由於覆寫零長度記憶體區段的空間和時間界限是不安全的,因此 MemorySegment::reinterpret 方法受到限制。在程式中呼叫它時,會使預設發出警告(請參見下面的「安全性」段落)。

上行呼叫(Upcalls)

有時將 Java 程式碼作為函式指標傳遞給外部函式是很有用的。我們可以通過使用 Linker 對上行呼叫的支援來做到。下面將逐步建立一個更複雜的範例,以展示 Linker 的全部功能,包括跨越 Java/原生邊界的程式碼和資料的完全雙向交互操作。

在標準 C 函式庫中定義的函式:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

為了從 Java 代碼呼叫 qsort 函式,我們需要先建立下行呼叫的方法句柄:

Linker linker = Linker.nativeLinker();
MethodHandle qsort = linker.downcallHandle(
    linker.defaultLookup().find("qsort").get(),
    FunctionDescriptor.ofVoid(ADDRESS, JAVA_LONG, JAVA_LONG, ADDRESS)
);

此處使用 JAVA_LONG 佈局去映射 size_t 型別,並對第一個指標參數(陣列指標)和最後一個參數(函式指標)都使用 ADDRESS 佈局。

qsort 用自定義比較函式 compar(以函式指標傳入)對陣列元素進行排序。因此在執行下行呼叫方法句柄時,我們需要傳遞函式指標給方法句柄的 invokeExact 方法。Linker::upcallStub 幫助我們使用現有的方法句柄建立函式指標。

首先,我們撰寫靜態比較器方法去比較兩個 int 值,它們被間接表示為 MemorySegment 物件:

class Qsort {
    static int qsortCompare(MemorySegment elem1, MemorySegment elem2) {
        return Integer.compare(elem1.get(JAVA_INT, 0), elem2.get(JAVA_INT, 0));
    }
}

第二,我們建立指向上例比較器方法的方法句柄:

MethodHandle comparHandle
    = MethodHandles.lookup()
                   .findStatic(Qsort.class, "qsortCompare",
                               MethodType.methodType(int.class,
                                                     MemorySegment.class,
                                                     MemorySegment.class));

第三,現在我們有了比較器的方法句柄,我們可以使用 Linker::upcallStub 建立函式指標。就像下行呼叫一樣,我們使用函式描述符來描述函式指標的簽章:

MemorySegment comparFunc
    = linker.upcallStub(comparHandle,
                        /* 由 Java 方法實作的 C 函式 */
                        FunctionDescriptor.of(JAVA_INT,
                                            ADDRESS.withTargetLayout(JAVA_INT),
                                            ADDRESS.withTargetLayout(JAVA_INT)),
                        Arena.ofAuto());

我們終於有了一個記憶體區段 comparFunc,它指向一個可以用來呼叫比較器的 stub,所以現在我們有了執行 qsort 下行呼叫句柄所需的一切:

try (Arena arena = Arena.ofConfined()) {
    MemorySegment array
        = arena.allocateFrom(ValueLayout.JAVA_INT,
                             0, 9, 3, 4, 6, 5, 1, 8, 2, 7);
    qsort.invoke(array, 10L, ValueLayout.JAVA_INT.byteSize(), comparFunc);
    int[] sorted = array.toArray(JAVA_INT);    // [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
}

這段程式碼建立一個堆外陣列,複製 Java 陣列的內容,然後將該堆外陣列與比較器函式(從原生連結器獲得)一起傳遞給 qsort 句柄。呼叫後,堆外陣列的內容將根據我們的比較器函式(以 Java 程式碼撰寫)進行排序。然後,我們從區段中提取一個新的 Java 陣列,其中包含已排序的元素。

記憶體區段和位元組緩衝區(Byte Buffer)

java.nio.channels 提供了豐富的功能來執行檔案和 Socket 的 I/O 操作,並使用 ByteBuffer 物件來表示。客戶端必須先將資料放進位元組緩衝區後才能寫入通道;而從通道讀取資料後,客戶端必須從位元組緩衝區中提取數據。下列程式碼用 FileChannel 將檔案的內容讀入堆外位元組緩衝區,每次 1024 位元組:

try (FileChannel channel = FileChannel.open(Path.of(FILE_URI))) {
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    int bytesRead;
    while ((bytesRead = channel.read(buffer)) != -1) {
        // 提取並處理緩衝區內容 ...
        buffer.clear();
    }
}

由於位元組緩衝區可能小於檔案大小,因此程式碼必須重複從通道讀取,然後清除位元組緩衝區,以便為下一個讀取操作做準備。

在這種低階緩衝區分配與管理的背景下,我們無法直接控制堆外位元組緩衝區的釋放,只能等垃圾回收器回收。如果一定需要立即釋放,我們只能依賴非標準、非確定性(non-deterministic)的技巧,例如呼叫 sun.misc.Unsafe::invokeCleaner 方法。

JEP 454 FFM API 能夠將通道與記憶體區段/競技場的標準且具確定性的釋放功能結合在一起。MemorySegment::asByteBuffer 方法允許將任何的記憶體區段當成位元組緩衝區使用。該位元組緩衝區的生命週期由記憶體區段的時間界限所決定,而時間界限又由分配該記憶體區段的競技場所設置。我們可以繼續使用位元組緩衝區讀寫通道,但現在可以控制何時去釋放位元組緩衝區的記憶體空間。

以下是修改後的範例。當 try-with-resources 關閉競技場時,該緩衝區的記憶體將被釋放:

// try-with-resources 管理兩個資源:通道與競技場
try (FileChannel channel = FileChannel.open(Path.of(FILE_URI));
     Arena offHeap       = Arena.ofConfined()) {
    ByteBuffer buffer = offHeap.allocate(1024).asByteBuffer();
    int readBytes;
    while ((readBytes = channel.read(buffer)) != -1) {
        // 拆封並處理緩衝區內容 ...
        buffer.clear();
    }
} // 緩衝區記憶體在此釋放

MemorySegment::ofBuffer 方法允許建立由同一記憶體空間所支持的記憶體區段,並將任何位元組緩衝區當成記憶體區段。下列範例使用從堆外位元組緩衝區生成的記憶體區段,並傳入接受 char * 的原生 strlen 函式:

void readString(ByteBuffer offheapString) {
    MethodHandle strlen = Linker.nativeLinker().downcallHandle(
        linker.defaultLookup().find("strlen").get(), FunctionDescriptor.of(JAVA_LONG, ADDRESS)
    );
    long len = strlen.invokeExact(MemorySegment.ofBuffer(offheapString));
    // ...
}

位元組緩衝區在許多 Java 程式中都可以看到,因為它長期以來是唯一受到支援的方式,用來將堆外資料傳遞給原生程式碼。然而對原生程式碼來說,要存取位元組緩衝區的資料並不容易,因為必須要先呼叫 JNI 函式來取得位元組緩衝區背後對應的記憶體空間指標。

相比之下,原生程式碼能夠輕易地存取記憶體區段中的資料。當 Java 程式碼將 MemorySegment 物件傳遞給原生程式碼時,FFM API 會傳遞記憶體區段的基底位址(也就是該記憶體空間的指標),而不是 MemorySegment 物件本身的位址。

安全性

大多數 JEP 454 FFM API 在設計上是安全的。過去許多需要使用 JNI 和原生程式碼的場景,現在都可以呼叫 FFM API 中永遠不會損害 Java 平台完整性的方法來解決。例如,JNI 的一個重要用途——靈活的記憶體分配和釋放——現在由記憶體區段和競技場所支援,並且不需要原生程式碼。

然而,部分 FFM API 本質上是不安全的。例如,Java 程式碼可以用 Linker 建立下行呼叫方法句柄,但指定與外部函式不相容的參數型別。此時呼叫方法句柄時將發生失敗(與在 JNI 中呼叫原生方法時相同):VM 崩潰或原生程式碼的未定義行為。這種失敗無法被 Java 執行環境預防,也無法被 Java 程式碼捕獲。

FFM API 還可以建立不安全的記憶體區段,即由使用者提供空間和時間界限,且無法被 Java 執行環境驗證的記憶體區段(請參閱 MemorySegment::reinterpret)。

換句話說,Java 程式碼和原生程式碼之間的任何交互都可能損害 Java 平台的完整性。因此,FFM API 中的不安全方法受到限制。因此雖然我們可以使用它們,但預設情況下會在執行時發出警告。例如:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Linker::downcallHandle has been called by com.foo.Server in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

此類警告(標準錯誤流)會為每個呼叫受限方法的程式碼的模組最多發出一次訊息。

若要允許模組 M 中的程式碼使用不安全方法而不發出警告,請在 java 啟動器命令列上指定 --enable-native-access=M 選項,並且使用逗號分隔以指定多個模組;指定 ALL-UNNAMED 以關閉類別路徑上所有程式碼的使用警告。

此外,可執行 JAR 中的 manifest 屬性 Enable-Native-Access: ALL-UNNAMED 可用於關閉類別路徑上所有程式碼的使用警告,並且不能使用其他模組名稱作為屬性值。

當存在 --enable-native-access 選項時,從指定模組清單之外使用任何不安全方法都會導致拋出 IllegalCallerException,而不是發出警告。在未來的版本中,可能需要此選項才能使用不安全方法;也就是說,如果該選項不存在,則使用不安全方法將不會導致警告,而是導致 IllegalCallerException

為了確保 Java 程式碼與原生程式碼交互方式的一致性,相關的 JEP 建議以類似的方式限制 JNI 的使用。我們仍然可以呼叫原生方法,並且原生程式碼可以呼叫不安全的 JNI 函式,但需要 --enable-native-access 選項來避免警告與之後的異常。這與更廣泛的路線圖一致,即「使 Java 平台開箱即用」,要求終端用戶或應用程式開發人員選擇加入不安全活動,例如破壞強封裝或連結到未知程式碼。

總結

JEP 454 的 Foreign Function & Memory API 代表了 Java 平台在與原生程式碼協作方面的重大進展。它不僅解決了長期以來 JNI 的各種問題,還提供了更安全、更有效率、且更易於使用的記憶體管理機制和原生程式碼交互方式,讓開發者能夠更自信地整合原生程式碼。

JEP 454 展現出成為 Java 生態系統中關鍵基礎設施的潛力。對於需要處理高效能的工作負載,或大量原生程式碼整合的開發團隊來說,這無疑是一個值得關注和投資的技術方向。儘管它仍然具有一定的複雜度,但對於需要與原生函式庫進行交互的 Java 應用程式來說,它是非常有價值的新工具。

本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

3 × four =

返回頂端