為了達成 Write once, run anywhere 的目標,早期的 Java 平台中隱含了未正式開放的程式碼,以利程式在不同的作業系統中與記憶體溝通。近長年 Java 開發社群一直在努力提升 Java 平台的安全性和可靠性。在 JEP 454 發表之後,JDK 23 中 JEP 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
中記憶體存取方法的安全且高性能替代品:
java.lang.invoke.VarHandle
:在 JDK 9(JEP 193)引入,提供安全有效地操作堆上記憶體(on-heap)的方法,即物件的欄位、類別的靜態欄位和陣列的元素java.lang.foreign.MemorySegment
:在 JDK 22(JEP 454)引入,提供安全有效地存取堆外記憶體(off-heap)的方法,有時與VarHandle
合作。
這些標準 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
中的記憶體存取方法,包括以下幾個主要步驟:
- 在 JDK 23 中,所有記憶體存取方法將被標記為棄用
- 引入新的命令列選項
--sun-misc-unsafe-memory-access={allow|warn|debug|deny}
,讓開發者可以控制這些方法的使用並評估影響 - 在未來的 JDK 版本中,逐步提高警告級別,最終在使用這些方法時拋出異常
- 最後,在適當的時機完全移除這些方法
棄用與移除細節
sun.misc.Unsafe
的記憶體存取方法可以分為三類:
- 用於存取堆上記憶體的方法(on-heap)
- 用於存取堆外記憶體的方法(off-heap)
- 同時用於存取堆上和堆外記憶體的方法(bimodal,雙模):此類方法接受一個參數,該參數會引用堆上物件或是為
null
以表示堆外存取
Java 將分階段棄用並移除這些方法;每個階段都會在單獨的 JDK 功能版本中進行:
- 棄用所有的記憶體存取方法(無論是堆上、堆外或雙模)以便將來移除:呼叫這些方法的程式碼在編譯時會產生棄用警告,提醒開發人員它們即將被移除。另外,上述提到的命令列選項讓開發人員和用戶能夠在使用這些方法時收到運行時警告
- 與棄用警告不同,
javac
自 2006 年以來就已經發出關於使用sun.misc.Unsafe
的警告:
warning: Unsafe 是內部專有 API,可能會在未來版本中移除
- 這些警告將持續發出,並且無法抑制
- 與棄用警告不同,
- 當使用記憶體存取方法時(無論是直接呼叫還是通過反射)發出警告:這將提醒開發人員和用戶注意這些方法即將被移除,並需要升級函式庫
- 當使用記憶體存取方法時(無論是直接呼叫還是通過反射)拋出異常:這將進一步提醒開發人員和用戶注意這些方法即將被移除
- 移除堆上方法:與堆上記憶體有關的存取方法將會先被移除,因為它們自 2017 年 JDK 9 以來已經有了標準替代品
- 移除堆外和雙模方法:剩餘的方法將稍後才會刪除,因為它們自 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)
上述方法用來取得偏移量或比例,然後用來呼叫雙模方法(見下文)以讀寫欄位或陣列元素。現在我們可以用 VarHandle
和 MemorySegment::ofArray
來處理。在極少數的情況下,這些方法單獨用於檢查和操作記憶體中物件的物理佈局,而這種用法沒有替代方案,也不會有標準 API 支援。
前三個方法已在 JDK 18 中被棄用,後兩個方法以及與這些方法相關的欄位在 JDK 23 中被聲明棄用,並會在未來的版本中移除:
int INVALID_FIELD_OFFSET
int ARRAY_[TYPE]_BASE_OFFSET
int ARRAY_[TYPE]_INDEX_SCALE
堆外記憶體方法
long allocateMemory(long bytes)
:使用Arena::allocate
或 FFM 中下行呼叫(downcall) C 函式庫的malloc()
函式long reallocateMemory(long address, long bytes)
:下行呼叫realloc()
void freeMemory(long address)
:使用Arena::close
或下行呼叫free()
void invokeCleaner(java.nio.ByteBuffer directBuffer)
:使用MemorySegment::asByteBuffer
void setMemory(long address, long bytes, byte value)
:使用MemorySegment::fill
void copyMemory(long srcAddress, long destAddress, long bytes)
:使用MemorySegment::copy
[type] get[Type](long address)
:使用MemorySegment.get(ValueLayout.Of[Type] layout, long offset)
void put[Type](long address, [type] x)
:使用MemorySegment.set(ValueLayout.of[Type] layout, long offset, [type] value)
long getAddress(long address)
:使用MemorySegment.get(ValueLayout.OfAddress layout, long offset)
void putAddress(long address, long x)
:使用MemorySegment.set(ValueLayout.ofAddress layout, long offset, MemorySegment value)
int addressSize()
(和int ADDRESS_SIZE
):使用ValueLayout.ADDRESS.byteSize()
雙模記憶體存取方法
[type] get[Type](Object o, long offset)
:使用VarHandle::get
取代void put[Type](Object o, long offset, [type] x)
:使用VarHandle::set
[type] get[Type]Volatile(Object o, long offset)
:使用VarHandle::getVolatile
void put[Type]Volatile(Object o, long offset, [type] x)
:使用VarHandle::setVolatile
void putOrdered[Type](Object o, long offset, [type] x)
:使用VarHandle::setRelease
[type] getAndAdd[Type](Object o, long offset, [type] delta)
:使用VarHandle::getAndAdd
[type] getAndSet[Type](Object o, long offset, [type] newValue)
:使用VarHandle::getAndSet
boolean compareAndSwap[Type](Object o, long offset, [type] expected, [type] x)
:使用VarHandle::compareAndSet
void setMemory(Object o, long offset, long bytes, byte value)
:使用MemorySegment::fill
或Arrays::fill
void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes)
:使用MemorySegment::copy
或System::arrayCopy
遷移範例
對於開發者來說,最想知道的是想了解如何從 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
來分配堆外緩衝區並執行三個操作的類別:
- 對
int
進行 volatile 揮發性寫入 - 對緩衝區子集進行批量初始化
- 將緩衝區數據複製到 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 開發者,保持學習並適應新的最佳實踐將有助於我們在這個不斷變化的技術環境中保持競爭力。
本篇文章的內容為老喬原創、二創或翻譯而來。雖已善盡校對、順稿與查核義務,但人非聖賢,多少仍會有疏漏之處難以避免。如果大家有任何問題、建議或指教,都歡迎在底下留言與老喬討論!