【基本功】Java魔法類:Unsafe應用解析

美團技術團隊發表於2019-02-15

大家好,我是程式設計師鼓勵師美美~

《基本功》專欄又上新了:Java中的Unsafe類在提升執行效率、增強底層資源操作能力方面有很大的用處。但如果在開發過程中使用不當,就會出現各種“莫名其妙”的問題。

本篇文章將會帶領你重新認識它,繞過“開發雷區”,豬事大吉。

Unsafe是位於sun.misc包下的一個類,主要提供一些用於執行低階別、不安全操作的方法,如直接訪問系統記憶體資源、自主管理記憶體資源等,這些方法在提升Java執行效率、增強Java語言底層資源操作能力方面起到了很大的作用。但由於Unsafe類使Java語言擁有了類似C語言指標一樣操作記憶體空間的能力,這無疑也增加了程式發生相關指標問題的風險。在程式中過度、不正確使用Unsafe類會使得程式出錯的機率變大,使得Java這種安全的語言變得不再“安全”,因此對Unsafe的使用一定要慎重。

本文對sun.misc.Unsafe公共API功能及相關應用場景進行介紹。

基本介紹

如下Unsafe原始碼所示,Unsafe類為一單例實現,提供靜態方法getUnsafe獲取Unsafe例項,當且僅當呼叫getUnsafe方法的類為引導類載入器所載入時才合法,否則丟擲SecurityException異常。

public final class Unsafe {
  // 單例物件
  private static final Unsafe theUnsafe;

  private Unsafe() {
  }
  @CallerSensitive
  public static Unsafe getUnsafe() {
    Class var0 = Reflection.getCallerClass();
    // 僅在引導類載入器`BootstrapClassLoader`載入時才合法
    if(!VM.isSystemDomainLoader(var0.getClassLoader())) {    
      throw new SecurityException("Unsafe");
    } else {
      return theUnsafe;
    }
  }
}

那如若想使用這個類,該如何獲取其例項?有如下兩個可行方案。

其一,從getUnsafe方法的使用限制條件出發,透過Java命令列命令-Xbootclasspath/a把呼叫Unsafe相關方法的類A所在jar包路徑追加到預設的bootstrap路徑中,使得A被引導類載入器載入,從而透過Unsafe.getUnsafe方法安全的獲取Unsafe例項。

java -Xbootclasspath/a: ${path}   // 其中path為呼叫Unsafe相關方法的類所在jar包路徑 

其二,透過反射獲取單例物件theUnsafe。

private static Unsafe reflectGetUnsafe() {
    try {
      Field field = Unsafe.class.getDeclaredField("theUnsafe");
      field.setAccessible(true);
      return (Unsafe) field.get(null);
    } catch (Exception e) {
      log.error(e.getMessage(), e);
      return null;
    }
}

功能介紹

【基本功】Java魔法類:Unsafe應用解析

如上圖所示,Unsafe提供的API大致可分為記憶體操作、CAS、Class相關、物件操作、執行緒排程、系統資訊獲取、記憶體屏障、陣列操作等幾類,下面將對其相關方法和應用場景進行詳細介紹。

記憶體操作

這部分主要包含堆外記憶體的分配、複製、釋放、給定地址值操作等方法。

//分配記憶體, 相當於C++的malloc函式
public native long allocateMemory(long bytes);
//擴充記憶體
public native long reallocateMemory(long address, long bytes);
//釋放記憶體
public native void freeMemory(long address);
//在給定的記憶體塊中設定值
public native void setMemory(Object o, long offset, long bytes, byte value);
//記憶體複製
public native void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes);
//獲取給定地址值,忽略修飾限定符的限制訪問限制。與此類似操作還有: getInt,getDouble,getLong,getChar等
public native Object getObject(Object o, long offset);
//為給定地址設定值,忽略修飾限定符的訪問限制,與此類似操作還有: putInt,putDouble,putLong,putChar等
public native void putObject(Object o, long offset, Object x);
//獲取給定地址的byte型別的值(當且僅當該記憶體地址為allocateMemory分配時,此方法結果為確定的)
public native byte getByte(long address);
//為給定地址設定byte型別的值(當且僅當該記憶體地址為allocateMemory分配時,此方法結果才是確定的)
public native void putByte(long address, byte x);

通常,我們在Java中建立的物件都處於堆內記憶體(heap)中,堆內記憶體是由JVM所管控的Java程式記憶體,並且它們遵循JVM的記憶體管理機制,JVM會採用垃圾回收機制統一管理記憶體。與之相對的是堆外記憶體,存在於JVM管控之外的記憶體區域,Java中對堆外記憶體的操作,依賴於Unsafe提供的操作堆外記憶體的native方法。

使用堆外記憶體的原因

  • 對垃圾回收停頓的改善。由於堆外記憶體是直接受作業系統管理而不是JVM,所以當我們使用堆外記憶體時,即可保持較小的堆內記憶體規模,從而在GC時減少回收停頓對於應用的影響。

  • 提升程式I/O操作的效能。通常在I/O通訊過程中,會存在堆內記憶體到堆外記憶體的資料複製操作,對於需要頻繁進行記憶體間資料複製且生命週期較短的暫存資料,都建議儲存到堆外記憶體。

典型應用

DirectByteBuffer是Java用於實現堆外記憶體的一個重要類,通常用在通訊過程中做緩衝池,如在Netty、MINA等NIO框架中應用廣泛。DirectByteBuffer對於堆外記憶體的建立、使用、銷燬等邏輯均由Unsafe提供的堆外記憶體API來實現。

下圖為DirectByteBuffer建構函式,建立DirectByteBuffer的時候,透過Unsafe.allocateMemory分配記憶體、Unsafe.setMemory進行記憶體初始化,而後構建Cleaner物件用於跟蹤DirectByteBuffer物件的垃圾回收,以實現當DirectByteBuffer被垃圾回收時,分配的堆外記憶體一起被釋放。

【基本功】Java魔法類:Unsafe應用解析

那麼如何透過構建垃圾回收追蹤物件Cleaner實現堆外記憶體釋放呢?

Cleaner繼承自Java四大引用型別之一的虛引用PhantomReference(眾所周知,無法透過虛引用獲取與之關聯的物件例項,且當物件僅被虛引用引用時,在任何發生GC的時候,其均可被回收),通常PhantomReference與引用佇列ReferenceQueue結合使用,可以實現虛引用關聯物件被垃圾回收時能夠進行系統通知、資源清理等功能。如下圖所示,當某個被Cleaner引用的物件將被回收時,JVM垃圾收集器會將此物件的引用放入到物件引用中的pending連結串列中,等待Reference-Handler進行相關處理。其中,Reference-Handler為一個擁有最高優先順序的守護執行緒,會迴圈不斷的處理pending連結串列中的物件引用,執行Cleaner的clean方法進行相關清理工作。

【基本功】Java魔法類:Unsafe應用解析

所以當DirectByteBuffer僅被Cleaner引用(即為虛引用)時,其可以在任意GC時段被回收。當DirectByteBuffer例項物件被回收時,在Reference-Handler執行緒操作中,會呼叫Cleaner的clean方法根據建立Cleaner時傳入的Deallocator來進行堆外記憶體的釋放。

【基本功】Java魔法類:Unsafe應用解析

CAS相關

如下原始碼釋義所示,這部分主要為CAS相關操作的方法。

/**
    *  CAS
  * @param o         包含要修改field的物件
  * @param offset    物件中某field的偏移量
  * @param expected  期望值
  * @param update    更新值
  * @return          true | false
  */

public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

什麼是CAS? 即比較並替換,實現併發演算法時常用到的一種技術。CAS操作包含三個運算元——記憶體位置、預期原值及新值。執行CAS操作的時候,將記憶體位置的值與預期原值比較,如果相匹配,那麼處理器會自動將該位置值更新為新值,否則,處理器不做任何操作。我們都知道,CAS是一條CPU的原子指令(cmpxchg指令),不會造成所謂的資料不一致問題,Unsafe提供的CAS方法(如compareAndSwapXXX)底層實現即為CPU指令cmpxchg。

典型應用

CAS在java.util.concurrent.atomic相關類、Java AQS、CurrentHashMap等實現上有非常廣泛的應用。如下圖所示,AtomicInteger的實現中,靜態欄位valueOffset即為欄位value的記憶體偏移地址,valueOffset的值在AtomicInteger初始化時,在靜態程式碼塊中透過Unsafe的objectFieldOffset方法獲取。在AtomicInteger中提供的執行緒安全方法中,透過欄位valueOffset的值可以定位到AtomicInteger物件中value的記憶體地址,從而可以根據CAS實現對value欄位的原子操作。

【基本功】Java魔法類:Unsafe應用解析

下圖為某個AtomicInteger物件自增操作前後的記憶體示意圖,物件的基地址baseAddress="0x110000",透過baseAddress+valueOffset得到value的記憶體地址valueAddress="0x11000c";然後透過CAS進行原子性的更新操作,成功則返回,否則繼續重試,直到更新成功為止。

【基本功】Java魔法類:Unsafe應用解析

執行緒排程

這部分,包括執行緒掛起、恢復、鎖機制等方法。

//取消阻塞執行緒
public native void unpark(Object thread);
//阻塞執行緒
public native void park(boolean isAbsolute, long time);
//獲得物件鎖(可重入鎖)
@Deprecated
public native void monitorEnter(Object o);
//釋放物件鎖
@Deprecated
public native void monitorExit(Object o);
//嘗試獲取物件鎖
@Deprecated
public native boolean tryMonitorEnter(Object o);

如上原始碼說明中,方法park、unpark即可實現執行緒的掛起與恢復,將一個執行緒進行掛起是透過park方法實現的,呼叫park方法後,執行緒將一直阻塞直到超時或者中斷等條件出現;unpark可以終止一個掛起的執行緒,使其恢復正常。

典型應用

Java鎖和同步器框架的核心類AbstractQueuedSynchronizer,就是透過呼叫LockSupport.park()LockSupport.unpark()實現執行緒的阻塞和喚醒的,而LockSupport的park、unpark方法實際是呼叫Unsafe的park、unpark方式來實現。

Class相關

此部分主要提供Class和它的靜態欄位的操作相關方法,包含靜態欄位記憶體定位、定義類、定義匿名類、檢驗&確保初始化等。

//獲取給定靜態欄位的記憶體地址偏移量,這個值對於給定的欄位是唯一且固定不變的
public native long staticFieldOffset(Field f);
//獲取一個靜態類中給定欄位的物件指標
public native Object staticFieldBase(Field f);
//判斷是否需要初始化一個類,通常需要使用在獲取一個類的靜態屬性的時候(因為一個類如果沒初始化,它的靜態屬性也不會初始化)。 此方法當且僅當ensureClassInitialized方法不生效的時候才返回false。
public native boolean shouldBeInitialized(Class<?> c);
//檢測給定的類是否已經初始化。通常需要使用在獲取一個類的靜態屬性的時候(因為一個類如果沒初始化,它的靜態屬性也不會初始化)。
public native void ensureClassInitialized(Class<?> c);
//定義一個類,此方法會跳過JVM的所有安全檢查,預設情況下,ClassLoader(類載入器)和ProtectionDomain(保護域)例項來源於呼叫者
public native Class<?> defineClass(String name, byte[] b, int off, int len, ClassLoader loader, ProtectionDomain protectionDomain);
//定義一個匿名類
public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);

典型應用

從Java 8開始,JDK使用invokedynamic及VM Anonymous Class結合來實現Java語言層面上的Lambda表示式。

  • invokedynamic: invokedynamic是Java 7為了實現在JVM上執行動態語言而引入的一條新的虛擬機器指令,它可以實現在執行期動態解析出呼叫點限定符所引用的方法,然後再執行該方法,invokedynamic指令的分派邏輯是由使用者設定的引導方法決定。

  • VM Anonymous Class:可以看做是一種模板機制,針對於程式動態生成很多結構相同、僅若干常量不同的類時,可以先建立包含常量佔位符的模板類,而後透過Unsafe.defineAnonymousClass方法定義具體類時填充模板的佔位符生成具體的匿名類。生成的匿名類不顯式掛在任何ClassLoader下面,只要當該類沒有存在的例項物件、且沒有強引用來引用該類的Class物件時,該類就會被GC回收。故而VM Anonymous Class相比於Java語言層面的匿名內部類無需透過ClassClassLoader進行類載入且更易回收。

在Lambda表示式實現中,透過invokedynamic指令呼叫引導方法生成呼叫點,在此過程中,會透過ASM動態生成位元組碼,而後利用Unsafe的defineAnonymousClass方法定義實現相應的函式式介面的匿名類,然後再例項化此匿名類,並返回與此匿名類中函式式方法的方法控制程式碼關聯的呼叫點;而後可以透過此呼叫點實現呼叫相應Lambda表示式定義邏輯的功能。下面以如下圖所示的Test類來舉例說明。

【基本功】Java魔法類:Unsafe應用解析

Test類編譯後的class檔案反編譯後的結果如下圖一所示(刪除了對本文說明無意義的部分),我們可以從中看到main方法的指令實現、invokedynamic指令呼叫的引導方法BootstrapMethods、及靜態方法lambda$main$0實現了Lambda表示式中字串列印邏輯)等。在引導方法執行過程中,會透過Unsafe.defineAnonymousClass生成如下圖二所示的實現Consumer介面的匿名類。其中,accept方法透過呼叫Test類中的靜態方法lambda$main$0來實現Lambda表示式中定義的邏輯。而後執行語句consumer.accept("lambda")其實就是呼叫下圖二所示的匿名類的accept方法。

【基本功】Java魔法類:Unsafe應用解析

物件操作

此部分主要包含物件成員屬性相關操作及非常規的物件例項化方式等相關方法。

//返回物件成員屬性在記憶體地址相對於此物件的記憶體地址的偏移量
public native long objectFieldOffset(Field f);
//獲得給定物件的指定地址偏移量的值,與此類似操作還有:getInt,getDouble,getLong,getChar等
public native Object getObject(Object o, long offset);
//給定物件的指定地址偏移量設值,與此類似操作還有:putInt,putDouble,putLong,putChar等
public native void putObject(Object o, long offset, Object x);
//從物件的指定偏移量處獲取變數的引用,使用volatile的載入語義
public native Object getObjectVolatile(Object o, long offset);
//儲存變數的引用到物件的指定的偏移量處,使用volatile的儲存語義
public native void putObjectVolatile(Object o, long offset, Object x);
//有序、延遲版本的putObjectVolatile方法,不保證值的改變被其他執行緒立即看到。只有在field被volatile修飾符修飾時有效
public native void putOrderedObject(Object o, long offset, Object x);
//繞過構造方法、初始化程式碼來建立物件
public native Object allocateInstance(Class<?> cls) throws InstantiationException;

典型應用

  • 常規物件例項化方式:我們通常所用到的建立物件的方式,從本質上來講,都是透過new機制來實現物件的建立。但是,new機制有個特點就是當類只提供有參的建構函式且無顯示宣告無參建構函式時,則必須使用有參建構函式進行物件構造,而使用有參建構函式時,必須傳遞相應個數的引數才能完成物件例項化。

  • 非常規的例項化方式:而Unsafe中提供allocateInstance方法,僅透過Class物件就可以建立此類的例項物件,而且不需要呼叫其建構函式、初始化程式碼、JVM安全檢查等。它抑制修飾符檢測,也就是即使構造器是private修飾的也能透過此方法例項化,只需提類物件即可建立相應的物件。由於這種特性,allocateInstance在java.lang.invoke、Objenesis(提供繞過類構造器的物件生成方式)、Gson(反序列化時用到)中都有相應的應用。

如下圖所示,在Gson反序列化時,如果類有預設建構函式,則透過反射呼叫預設建構函式建立例項,否則透過UnsafeAllocator來實現物件例項的構造,UnsafeAllocator透過呼叫Unsafe的allocateInstance實現物件的例項化,保證在目標類無預設建構函式時,反序列化不夠影響。

【基本功】Java魔法類:Unsafe應用解析

陣列相關

這部分主要介紹與資料操作相關的arrayBaseOffset與arrayIndexScale這兩個方法,兩者配合起來使用,即可定位陣列中每個元素在記憶體中的位置。

//返回陣列中第一個元素的偏移地址
public native int arrayBaseOffset(Class<?> arrayClass);
//返回陣列中一個元素佔用的大小
public native int arrayIndexScale(Class<?> arrayClass);

典型應用

這兩個與資料操作相關的方法,在java.util.concurrent.atomic 包下的AtomicIntegerArray(可以實現對Integer陣列中每個元素的原子性操作)中有典型的應用,如下圖AtomicIntegerArray原始碼所示,透過Unsafe的arrayBaseOffset、arrayIndexScale分別獲取陣列首元素的偏移地址base及單個元素大小因子scale。後續相關原子性操作,均依賴於這兩個值進行陣列中元素的定位,如下圖二所示的getAndAdd方法即透過checkedByteOffset方法獲取某陣列元素的偏移地址,而後透過CAS實現原子性操作。

【基本功】Java魔法類:Unsafe應用解析

記憶體屏障

在Java 8中引入,用於定義記憶體屏障(也稱記憶體柵欄,記憶體柵障,屏障指令等,是一類同步屏障指令,是CPU或編譯器在對記憶體隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後才可以開始執行此點之後的操作),避免程式碼重排序。

//記憶體屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障後,屏障後的load操作不能被重排序到屏障前
public native void loadFence();
//記憶體屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障後,屏障後的store操作不能被重排序到屏障前
public native void storeFence();
//記憶體屏障,禁止load、store操作重排序
public native void fullFence();

典型應用

在Java 8中引入了一種鎖的新機制——StampedLock,它可以看成是讀寫鎖的一個改進版本。StampedLock提供了一種樂觀讀鎖的實現,這種樂觀讀鎖類似於無鎖的操作,完全不會阻塞寫執行緒獲取寫鎖,從而緩解讀多寫少時寫執行緒“飢餓”現象。由於StampedLock提供的樂觀讀鎖不阻塞寫執行緒獲取讀鎖,當執行緒共享變數從主記憶體load到執行緒工作記憶體時,會存在資料不一致問題,所以當使用StampedLock的樂觀讀鎖時,需要遵從如下圖用例中使用的模式來確保資料的一致性。

【基本功】Java魔法類:Unsafe應用解析

如上圖用例所示計算座標點Point物件,包含點移動方法move及計算此點到原點的距離的方法distanceFromOrigin。在方法distanceFromOrigin中,首先,透過tryOptimisticRead方法獲取樂觀讀標記;然後從主記憶體中載入點的座標值 (x,y);而後透過StampedLock的validate方法校驗鎖狀態,判斷座標點(x,y)從主記憶體載入到執行緒工作記憶體過程中,主記憶體的值是否已被其他執行緒透過move方法修改,如果validate返回值為true,證明(x, y)的值未被修改,可參與後續計算;否則,需加悲觀讀鎖,再次從主記憶體載入(x,y)的最新值,然後再進行距離計算。其中,校驗鎖狀態這步操作至關重要,需要判斷鎖狀態是否發生改變,從而判斷之前copy到執行緒工作記憶體中的值是否與主記憶體的值存在不一致。

下圖為StampedLock.validate方法的原始碼實現,透過鎖標記與相關常量進行位運算、比較來校驗鎖狀態,在校驗邏輯之前,會透過Unsafe的loadFence方法加入一個load記憶體屏障,目的是避免上圖用例中步驟②和StampedLock.validate中鎖狀態校驗運算發生重排序導致鎖狀態校驗不準確的問題。

【基本功】Java魔法類:Unsafe應用解析

系統相關

這部分包含兩個獲取系統相關資訊的方法。

//返回系統指標的大小。返回值為4(32位系統)或8(64位系統)。
public native int addressSize();  
//記憶體頁的大小,此值為2的冪次方。
public native int pageSize();

典型應用

如下圖所示的程式碼片段,為java.nio下的工具類Bits中計算待申請記憶體所需記憶體頁數量的靜態方法,其依賴於Unsafe中pageSize方法獲取系統記憶體頁大小實現後續計算邏輯。

【基本功】Java魔法類:Unsafe應用解析

結語

本文對Java中的sun.misc.Unsafe的用法及應用場景進行了基本介紹,我們可以看到Unsafe提供了很多便捷、有趣的API方法。即便如此,由於Unsafe中包含大量自主操作記憶體的方法,如若使用不當,會對程式帶來許多不可控的災難。因此對它的使用我們需要慎之又慎。

參考資料

  • OpenJDK Unsafe source

  • Java Magic. Part 4: sun.misc.Unsafe

  • JVM crashes at libjvm.so

  • Java中神奇的雙刃劍--Unsafe

  • JVM原始碼分析之堆外記憶體完全解讀

  • 堆外記憶體 之 DirectByteBuffer 詳解

  • 《深入理解Java虛擬機器(第2版)》

作者簡介

璐璐,美團點評Java開發工程師。2017年加入美團點評,負責美團點評境內度假的後端開發。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559353/viewspace-2636126/,如需轉載,請註明出處,否則將追究法律責任。

相關文章