JDK 23 功能:JEP 471 棄用 sun.misc.Unsafe 中的記憶體存取方法

JEP 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal

為了達成 Write once, run anywhere 的目標,早期的 Java 平台中隱含了未正式開放的程式碼,以利程式在不同的作業系統中與記憶體溝通。近長年 Java 開發社群一直在努力提升 Java 平台的安全性和可靠性。在 JEP 454 發表之後,JDK 23JEP 471 提出要棄用並最終移除 sun.misc.Unsafe 類別中的記憶體存取方法。

這項提案的目的是為了鼓勵開發者使用更安全、更標準的 JEP 454 Foreign Function & Memory API(FFM API),從而提高 Java 應用程式的整體穩定性和安全性。本文將深入探討 JEP 471 的背景、動機、以及對 Java 開發者的影響。我們將分析這項變更的優缺點,並提供程式碼範例說明如何從使用 sun.misc.Unsafe 遷移到推薦的替代方案。

前言

sun.misc.Unsafe 類別自 2002 年引入以來,一直是 Java 開發者執行低階操作的重要工具。它的大多數方法(87 個中的 79 個)用來存取記憶體,無論是在 JVM 的垃圾收集器中或是在不受 JVM 控制的堆外記憶體中。

然而,正如其名稱所暗示,sun.misc.Unsafe 類別中的記憶體存取方法是不安全的。使用它們可能導致未定義的行為,甚至造成 JVM 崩潰。此類別原先並不是為了被客戶端廣泛使用而設計的。它們的引入是基於下列假設:

  • 專門提供 JDK 內部使用
  • 並且 JDK 內部的使用者在呼叫它們之前會執行詳盡的安全檢查
  • 最終會將等價於這些功能的安全標準 API 添加到 Java 平台中

因此,它們從未以標準 API 的形式發表。但是因為缺乏控管機制去限制客戶端使用此類別,導致許多第三方函式庫開發者也開始使用它以獲取更高的效能和更多的功能。

sun.misc.Unsafe 的亂象

由於沒有辦法阻止 sun.misc.Unsafe 在 JDK 之外被使用,它的記憶體存取方法成為了函式庫開發人員的便捷工具,藉此取得比標準 API 還要更多的功能和性能。例如:

  • sun.misc.Unsafe::compareAndSwap 可以在欄位上執行 CAS(比較並交換)操作而無需 java.util.concurrent.atomic API 的開銷
  • sun.misc.Unsafe::setMemory 可以操作堆外記憶體,而沒有 java.nio.ByteBuffer 的 2GB 限制
  • 依賴於 ByteBuffer 來操作堆外記憶體的函式庫,例如 Apache Hadoop 和 Cassandra,使用 sun.misc.Unsafe::invokeCleaner 即時釋放堆外記憶體來提高效率

不幸的是,並非所有函式庫在呼叫這些記憶體存取方法之前都會認真執行安全檢查,因此應用程式中存在著崩潰的風險。甚至在某些情況下,有些開發人員直接複製貼上線上討論區中不安全的範例程式碼(但其實有其他更好的解法)。這導致了 JVM 優化可能會被停用而讓整體性能變差,反而不如用一般 Java 陣列。

儘管如此,由於記憶體存取方法被廣泛使用,sun.misc.Unsafe 並沒有與 JDK 9(JEP 260)中的其他低階 API 一起封裝。在 JDK 22 中,它仍然可以開箱即用,等待著可用的安全支援替代方案。

露出曙光

隨著時間的推移,Java 平台不斷發展並引入了更標準的 API 來執行低階操作。它們是 sun.misc.Unsafe 中記憶體存取方法的安全且高性能替代品:

這些標準 API 保證不會出現未定義的行為、承諾長期穩定性,並且已經與 Java 平台的工具和文件整合在一起。基於上述 API 的可用性和完整性,現在應該可以棄用並最終刪除 sun.misc.Unsafe 中的記憶體存取方法,以確保 Java 平台具有預設的完整性,因此本功能是提高 Java 平台整體品質的必要步驟。

其他項目包括對 Java Native Interface(JEP 472站內介紹)和代理的動態載入(JEP 451)施加限制。這些努力將使 Java 平台更安全且性能更好,同時還能降低開發人員因為使用了在較新版本中被修改或不再支援的 API 的函式庫而被困在舊 JDK 版本上的風險。

JEP 471 概觀

JEP 471 的主要目的是:

  • 棄用不安全類別中的方法,並讓 Java 生態系做好準備,以便在未來的 JDK 版本中移除 sun.misc.Unsafe 中的記憶體存取方法
  • 幫助開發人員意識到他們的應用程式是否直接或間接依賴於 sun.misc.Unsafe 中的記憶體存取方法
  • 鼓勵函式庫開發人員從 sun.misc.Unsafe 遷移到其他受到支援的標準替代方案,以便應用程式能夠順利遷移到更新的 JDK 版本

優點

  • 提高 Java 平台的完整性和安全性:sun.misc.Unsafe 中的記憶體存取方法是非官方支援的,可能導致安全漏洞和程式崩潰。棄用這些方法將鼓勵開發人員使用更安全且官方支援的 API
  • 促使舊版本的程式往更新的 JDK 版本遷移:通過棄用 sun.misc.Unsafe,開發人員將被迫遷移到更新的 JDK 版本中使用替代 API,從而確保他們的應用程式保持強健並且可以與未來版本相容
  • 長期來看,可以簡化 JDK 的維護工作,並為 JVM 開發者提供更大的優化空間

缺點

  • 許多現有的程式庫和應用程式都依賴於 sun.misc.Unsafe 中的記憶體存取方法,棄用它們可能會導致這些程式碼需要進行重大的修改後才能與未來的 JDK 版本相容
  • sun.misc.Unsafe 遷移到替代 API 需要時間和精力,特別是對於大型和複雜的函式庫,甚至某些特定功能的程式可能需要重新設計
  • 某些極端情況下可能會輕微影響效能

介紹

JEP 471 提出了階段性計劃來棄用並最終移除 sun.misc.Unsafe 中的記憶體存取方法,包括以下幾個主要步驟:

  1. 在 JDK 23 中,所有記憶體存取方法將被標記為棄用
  2. 引入新的命令列選項 --sun-misc-unsafe-memory-access={allow|warn|debug|deny},讓開發者可以控制這些方法的使用並評估影響
  3. 在未來的 JDK 版本中,逐步提高警告級別,最終在使用這些方法時拋出異常
  4. 最後,在適當的時機完全移除這些方法

棄用與移除細節

sun.misc.Unsafe 的記憶體存取方法可以分為三類:

  • 用於存取堆上記憶體的方法(on-heap)
  • 用於存取堆外記憶體的方法(off-heap)
  • 同時用於存取堆上和堆外記憶體的方法(bimodal,雙模):此類方法接受一個參數,該參數會引用堆上物件或是為 null 以表示堆外存取

Java 將分階段棄用並移除這些方法;每個階段都會在單獨的 JDK 功能版本中進行:

  1. 棄用所有的記憶體存取方法(無論是堆上、堆外或雙模)以便將來移除:呼叫這些方法的程式碼在編譯時會產生棄用警告,提醒開發人員它們即將被移除。另外,上述提到的命令列選項讓開發人員和用戶能夠在使用這些方法時收到運行時警告
    • 與棄用警告不同,javac 自 2006 年以來就已經發出關於使用 sun.misc.Unsafe 的警告:
    warning: Unsafe 是內部專有 API,可能會在未來版本中移除
    • 這些警告將持續發出,並且無法抑制
  2. 當使用記憶體存取方法時(無論是直接呼叫還是通過反射)發出警告:這將提醒開發人員和用戶注意這些方法即將被移除,並需要升級函式庫
  3. 當使用記憶體存取方法時(無論是直接呼叫還是通過反射)拋出異常:這將進一步提醒開發人員和用戶注意這些方法即將被移除
  4. 移除堆上方法:與堆上記憶體有關的存取方法將會先被移除,因為它們自 2017 年 JDK 9 以來已經有了標準替代品
  5. 移除堆外和雙模方法:剩餘的方法將稍後才會刪除,因為它們自 2023 年 JDK 22 後才有標準替代品

關於時間計劃,安排在下列的 JDK 版本中實施各個階段:

  • 階段 1:通過本 JEP,在 JDK 23 中實施
  • 階段 2:發出運行時警告,在 JDK 25 或之前實施
  • 階段 3:預設拋出異常,在 JDK 26 或之後實施
  • 階段 4 和 5:移除方法,在 JDK 26 之後的版本中實施

允許使用 sun.misc.Unsafe 中的記憶體存取方法

大部分的 Java 開發人員通常不會在程式碼中直接使用 sun.misc.Unsafe 類別。然而,許多函式庫會直接或間接呼叫 sun.misc.Unsafe 的記憶體存取方法。從 JDK 23 開始,我們可以利用新的命令列選項 --sun-misc-unsafe-memory-access={allow|warn|debug|deny} 來評估棄用和移除這些方法會帶來哪些影響。這個選項在精神和形式上都類似於 JDK 9 中 JEP 261 引入的 --illegal-access 選項。它的工作原理如下:

  • --sun-misc-unsafe-memory-access=allow:允許使用記憶體存取方法,並且運行時不會發出警告
  • --sun-misc-unsafe-memory-access=warn:允許使用記憶體存取方法,但僅在第一次被使用時(無論是直接呼叫還是通過反射)發出警告。也就是說,無論使用哪種方法,或是被使用了多少次,最多都只會發出一個警告
  • --sun-misc-unsafe-memory-access=debug:允許使用記憶體存取方法,並在每次被使用時(無論是直接呼叫還是通過反射)發出警告和堆疊追蹤
  • --sun-misc-unsafe-memory-access=deny:禁止使用記憶體存取方法,並在每次使用時(無論是直接呼叫還是通過反射)拋出 UnsupportedOperationException

選項值 warn 啟用的警告範例如下:

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::setMemory has been called by com.foo.bar.Server (file:/tmp/foobarserver/thing.jar)
WARNING: Please consider reporting this to the maintainers of com.foo.bar.Server
WARNING: sun.misc.Unsafe::setMemory will be removed in a future release

隨著各階段逐步進行,--sun-misc-unsafe-memory-access 的預設值會隨之調整:

  • 階段 1:預設值為 allow,因此每次執行程式時都會包含 --sun-misc-unsafe-memory-access=allow
  • 階段 2:預設值為 warn,但可以重設為 allow 從而避免產生警告
  • 階段 3:預設值為 deny,但可以重設為 warn 以便接收警告而不是拋出異常,不過無法設定成 allow 來避免警告
  • 階段 5:當所有記憶體存取方法都被移除後,--sun-misc-unsafe-memory-access 選項將被忽略,並會在之後的版本移除

下列工具可以幫助我們了解程式碼如何呼叫 sun.misc.Unsafe 中已棄用的方法:

  • 程式碼使用到 sun.misc.Unsafe 的方法時,javac 會發出棄用警告,而這些警告可以用 @SuppressWarnings("removal") 來抑制
  • 在命令列上啟用 JDK Flight Recorder(JFR)並執行到棄用方法時,JFD 會記錄一個 jdk.DeprecatedInvocation 事件。我們可以利用此事件來找出是否有使用了 sun.misc.Unsafe 中的方法

以下是建立 JFR 記錄並顯示 jdk.DeprecatedInvocation 事件的步驟:

$ java -XX:StartFlightRecording:filename=recording.jfr ...
$ jfr print --events jdk.DeprecatedInvocation recording.jfr

sun.misc.Unsafe 記憶體存取方法及其替代方案

堆上記憶體方法

  • long objectFieldOffset(Field f)
  • long staticFieldOffset(Field f)
  • Object staticFieldBase(Field f)
  • int arrayBaseOffset(Class<?> arrayClass)
  • int arrayIndexScale(Class<?> arrayClass)

上述方法用來取得偏移量或比例,然後用來呼叫雙模方法(見下文)以讀寫欄位或陣列元素。現在我們可以用 VarHandleMemorySegment::ofArray 來處理。在極少數的情況下,這些方法單獨用於檢查和操作記憶體中物件的物理佈局,而這種用法沒有替代方案,也不會有標準 API 支援。

前三個方法已在 JDK 18 中被棄用,後兩個方法以及與這些方法相關的欄位在 JDK 23 中被聲明棄用,並會在未來的版本中移除:

  • int INVALID_FIELD_OFFSET
  • int ARRAY_[TYPE]_BASE_OFFSET
  • int ARRAY_[TYPE]_INDEX_SCALE

堆外記憶體方法

雙模記憶體存取方法

遷移範例

對於開發者來說,最想知道的是想了解如何從 sun.misc.Unsafe 遷移到推薦的替代方案。以下是幾個簡單的範例,說明如何改寫使用了 sun.misc.Unsafe 的程式碼。

堆上記憶體存取

假設類別 Foo 有一個 int 欄位 x,我們希望原子式地將其值加倍。原本用 sun.misc.Unsafe 可以滿足這一點:

class Foo {
    private static final Unsafe UNSAFE = ...;    // sun.misc.Unsafe 物件
    private static final long X_OFFSET;

    static {
        try {  // 堆上記憶體存取
            X_OFFSET = UNSAFE.objectFieldOffset(Foo.class.getDeclaredField("x"));
        } catch (Exception ex) { throw new AssertionError(ex); }
    }

    private int x;

    public boolean tryToDoubleAtomically() {
        int oldValue = x;
        return UNSAFE.compareAndSwapInt(this, X_OFFSET, oldValue, oldValue * 2);
    }
}

現在可以用標準的 VarHandle API 來完成:

class Foo {
    private static final VarHandle X_VH;  // 改用 VarHandle 物件

    static {
        try {
            X_VH = MethodHandles.lookup().findVarHandle(Foo.class, "x", int.class);
        } catch (Exception ex) { throw new AssertionError(ex); }
    }

    private int x;

    public boolean tryAtomicallyDoubleX() {
        int oldValue = x;
        return X_VH.compareAndSet(this, oldValue, oldValue * 2);
    }
}

上述例子說明了如何使用 VarHandle 來代替 sun.misc.Unsafe 去進行原子操作,因為它提供了類似的功能,並且具有更好的類型安全性和更清晰的 API。

接下來的例子是用 sun.misc.Unsafe 完成陣列中元素的揮發性寫入:

class Foo {
    private static final Unsafe UNSAFE = ...;

    // 堆上記憶體存取
    private static final int ARRAY_BASE = UNSAFE.arrayBaseOffset(int[].class);
    private static final int ARRAY_SCALE = UNSAFE.arrayIndexScale(int[].class);

    private int[] a = new int[10];

    public void setVolatile(int index, int value) {
        if (index < 0 || index >= a.length)
            throw new ArrayIndexOutOfBoundsException(index);
        UNSAFE.putIntVolatile(a, ARRAY_BASE + ARRAY_SCALE * index, value);
    }
}

改用 VarHandle 之後:

class Foo {
    private static final VarHandle AVH = MethodHandles.arrayElementVarHandle(int[].class);

    private int[] a = new int[10];

    public void setVolatile(int index, int value) {
        AVH.setVolatile(a, index, value);
    }
}

堆外記憶體存取

下例是使用 sun.misc.Unsafe 來分配堆外緩衝區並執行三個操作的類別:

  1. int 進行 volatile 揮發性寫入
  2. 對緩衝區子集進行批量初始化
  3. 將緩衝區數據複製到 Java int 陣列中
class OffHeapIntBuffer {
    private static final Unsafe UNSAFE = ...;
    private static final int ARRAY_BASE = UNSAFE.arrayBaseOffset(int[].class);
    private static final int ARRAY_SCALE = UNSAFE.arrayIndexScale(int[].class);

    private final long size;
    private long bufferPtr;

    public OffHeapIntBuffer(long size) {
        this.size = size;
        this.bufferPtr = UNSAFE.allocateMemory(size * ARRAY_SCALE);
    }

    public void deallocate() {
        if (bufferPtr == 0) return;
        UNSAFE.freeMemory(bufferPtr);
        bufferPtr = 0;
    }

    private boolean checkBounds(long index) {
        if (index < 0 || index >= size)
            throw new IndexOutOfBoundsException(index);
        return true;
    }

    public void setVolatile(long index, int value) {
        checkBounds(index);
        UNSAFE.putIntVolatile(null, bufferPtr + ARRAY_SCALE * index, value);
    }

    public void initialize(long start, long n) {
        checkBounds(start);
        checkBounds(start + n-1);
        UNSAFE.setMemory(bufferPtr + start * ARRAY_SCALE, n * ARRAY_SCALE, 0);
    }

    public int[] copyToNewArray(long start, int n) {
        checkBounds(start);
        checkBounds(start + n-1);
        int[] a = new int[n];
        UNSAFE.copyMemory(null, bufferPtr + start * ARRAY_SCALE, a, ARRAY_BASE, n * ARRAY_SCALE);
        return a;
    }
}

可以用標準的 Arena 和 MemorySegment API 改寫,程式碼變得相當精簡且清晰:

class OffHeapIntBuffer {
    private static final VarHandle ELEM_VH = ValueLayout.JAVA_INT.arrayElementVarHandle();

    private final Arena arena;
    private final MemorySegment buffer;

    public OffHeapIntBuffer(long size) {
        this.arena  = Arena.ofShared();
        this.buffer = arena.allocate(ValueLayout.JAVA_INT, size);
    }

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

    public void setVolatile(long index, int value) {
        ELEM_VH.setVolatile(buffer, 0L, index, value);
    }

    public void initialize(long start, long n) {
        buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start,
                       ValueLayout.JAVA_INT.byteSize() * n)
              .fill((byte) 0);
    }

    public int[] copyToNewArray(long start, int n) {
        return buffer.asSlice(ValueLayout.JAVA_INT.byteSize() * start,
                              ValueLayout.JAVA_INT.byteSize() * n)
                     .toArray(ValueLayout.JAVA_INT);
    }
}

風險

多年以來,在引入標準替代方案後,sun.misc.Unsafe 中與記憶體存取無關的方法已被棄用,並且其中許多方法已經被刪除:

  • 在 JDK 9 中引入 java.lang.invoke.MethodHandles.Lookup::defineClass 後,sun.misc.Unsafe::defineClass 在 JDK 11 中被移除
  • JDK 15 中引入 MethodHandles.Lookup::defineHiddenClass 後,sun.misc.Unsafe::defineAnonymousClass 在 JDK 17 中被移除
  • 在 JDK 15 中引入 MethodHandles.Lookup::ensureInitialized 後,sun.misc.Unsafe::{ensureClass,shouldBe}Initialized 在 JDK 22 中被移除
  • sun.misc.Unsafe 中的六個雜項方法在 JDK 22 中被棄用並準備刪除,因為已有標準替代方案可用

移除上述這些相對晦澀的方法對 Java 生態系統的影響非常小。相對來說,記憶體存取方法更為人所知。因此為了有效地吸引函式庫開發人員的注意力並獲得最大的可見性,本次透過 JEP 471 來發布並說明,而不是簡單地通過 CSR 請求來棄用和移除它們。

另外,標準 API 無法完全取代 sun.misc.Unsafe 的堆上記憶體存取方法。例如:我們可以用 Unsafe::objectFieldOffset 來取得物件中欄位的偏移量,然後使用 Unsafe::putInt 在該偏移量處寫入一個 int 值,並且不用理會該欄位是否為 int。然而,標準的 VarHandle API 是通過名稱和類型來引用欄位,無法用偏移量在如此低的層級檢查或操作物件。依賴於欄位偏移量的用法實際上是在揭示或利用了 JVM 的實作細節,因此 Java 開發小組認為此類用法不需要標準 API 支援。

最後,函式庫可能會使用 UNSAFE.getInt(array, arrayBase + offset) 來存取堆中的陣列元素並省略邊界檢查。這種用法通常用在隨機存取陣列元素的場景,因為 JIT 對於陣列元素的循序存取(無論是通過普通的陣列索引操作或是 MemorySegment API)會消除邊界檢查。

Java 開發小組認為不需要開放標準 API 去支援無邊界檢查機制的隨機存取陣列元素。相較於 sun.misc.Unsafe 原有的堆上記憶體存取方法,雖然透過陣列索引操作或 MemorySegment API 進行隨機存取會有些微的效能損失,但卻能大幅提升安全性和可維護性。特別是使用標準 API 能保證在所有平台和所有 JDK 版本上都能夠可靠地運作,即使未來 JVM 對陣列的實作方式有所改變。

未來的工作

在 JEP 471 將 79 個記憶體存取方法棄用並準備移除後,sun.misc.Unsafe 將只包含三個未被棄用的方法:

  • pageSize:它將被單獨棄用和移除。替代方案是用下行呼叫直接從作業系統獲取記憶體頁面大小
  • throwException:它將被單獨棄用和移除。從歷史記錄中來查看,JDK 中的方法曾使用它將 checked exceptions 封裝在 unchecked exceptions 中,但這些方法(例如 Class::newInstance)現在已被棄用
  • allocateInstance:它會是 sun.misc.Unsafe 中唯一暫時保留的方法,因為某些序列化函式庫將其用於反序列化。未來會提供它的標準替代方案

總結

JEP 471 代表了 Java 平台正朝著更安全、更可靠方向發展。雖然這可能會為少數開發者帶來短期的不便,但長遠來看,這將使 Java 生態系統更加健康和穩定。我們應該積極擁抱這個變化,並開始計劃如何將程式碼從 sun.misc.Unsafe 遷移到推薦的替代方案,以確保其應用程式與未來的 JDK 版本相容。

隨著 Java 平台的不斷發展,我們可以期待會看到更多類似的改進去提高安全性、可靠性和效能。作為 Java 開發者,保持學習並適應新的最佳實踐將有助於我們在這個不斷變化的技術環境中保持競爭力。

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

發佈留言

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

15 + 15 =

返回頂端