Java源碼閱讀之:Unsafe魔法類

Unsafe類

內容參考節選:

https://blog.csdn.net/qq_34436819/article/details/102637289

https://blog.csdn.net/qq_34436819/article/details/102723579

https://www.jianshu.com/p/db8dce09232d

https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html

一:問題

  1. Unsafe是什麼類
  2. 與原子類原子操作有什麼關係
  3. Unsafe類有什麼功能

二:Unsafe類簡介

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

Unsafe類中方法很多,但大致可以分爲8大類。CAS操作、內存操作、線程調度、數組相關、對象相關操作、Class相關操作、內存屏障相關、系統相關。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-pzwUBwF4-1577278059389)(https://p1.meituan.net/travelcube/f182555953e29cec76497ebaec526fd1297846.png)]

三:獲取Unsafe實例

Unsafe類被final修飾了,表示Unsafe不能被繼承;同時Unsafe的構造函數用Private修飾,表示外部無法直接通過構造方法去創建實例。實際上Unsafe是一個單例對象。下面使Unsafe有關構造定義方面的代碼。

public final class Unsafe { 
    private static final Unsafe theUnsafe;
    // 構造函數私有化,不能通過構造方法創建實例
    private Unsafe() {
    }

    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class var0 = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(var0.getClassLoader())) {
            throw new SecurityException("Unsafe");
        } else {
            return theUnsafe;
        }
    }
}

Unsafe類中有一個getUnsafe()方法,可以返回一個Unsafe對象theUnsafe,但是實際開發中,在自己開發的類中是無法通過Unsafe.getUnsafe()方法來獲取實例的,會拋出SecurityException異常。

public class theUnsafe {
    public static void main(String[] args) {
        Unsafe unsafe = Unsafe.getUnsafe();
    }
}

Exception in thread "main" java.lang.SecurityException: Unsafe
	at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
	at theUnsafe.main(theUnsafe.java:5)

爲什麼會出現異常:

爲什麼會出現SecurityException異常呢?這是因爲在Unsafe類的getUnsafe()方法中,它做了一層校驗,判斷當前類(Demo)的類加載器(ClassLoader)是不是啓動類加載器(Bootstrap ClassLoader),如果不是,則會拋出SecurityException異常。在JVM的類加載機制中,自定義的類使用的類加載器是應用程序類加載器(Application ClassLoader),所以這個時候校驗失敗,會拋出異常。

那麼如何才能獲取到Unsafe類的實例呢?有兩種方案。
第一方案:將我們自定義的類(如Demo類)所在的jar包所在的路徑通過-Xbootclasspath參數添加到Java命令中,這樣當程序啓動時,Bootstrap ClassLoader會加載Demo類,這樣校驗就通過了。顯然這種方式比較麻煩,而且不太實用,因爲在項目中,可能需要在很多地方都使用Unsafe類,如果通過Java命令行這種方式去指定,就會很麻煩,而且容易出現紕漏,因此不會常用。
第二種方案:通過反射來創建Unsafe類的實例。

public static void main(String[] args) {
    try {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        // 忽略訪問權限修飾符的安全檢查
        field.setAccessible(true);
        // 因爲theUnsafe字段在Unsafe類中是一個靜態字段,所以通過Field.get()獲取字段值時,可以傳null獲取
        Unsafe unsafe = (Unsafe) field.get(null);
        // 控制檯能打印出對象哈希碼
        System.out.println(unsafe);
    } catch (Exception e) {
        e.printStackTrace();
    }
}		

四:Unsafe功能介紹

在上文中將Unsafe的功能主要分爲8大類。下面具體對每一類進行解析。

4.1 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的全稱是Compare And Swap,翻譯過來就是比較並交換。是實現併發算法時常用到的一種技術。CAS操作包含三個操作數——內存位置、預期原值及新值。假設內存中數據的值爲V,舊的預期值爲A,新的修改值爲B。那麼CAS操作可以分爲三個步驟:1)將舊的預期值A與內存中的值V比較;2)如果A與V的值相等,那麼就將V的值設置爲B;3)返回操作是否成功,下圖爲Atomic Integer類調用Unsafe的CAS操作示意圖。在多處理器的機器上,當有多個線程對內存中的數據進行CAS操作時,處理器能保證只會有一個線程能修改成功。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-NPBQNQA7-1577278059391)(https://p0.meituan.net/travelcube/6e8b1fe5d5993d17a4c5b69bb72ac51d89826.png)]

在Java中可以通過Unsafe類實現CAS操作,而Unsafe類最終調用的是native方法,即具體實現是由JVM中的方法實現的。而JVM中通過C++調用處理器的指令cmpxchg來實現的。
CAS產生的問題
  1. ABA問題。在CAS操作時會先檢查值有沒有變化,如果沒有變化則執行更新操作,如果有變化則不執行更新操作。假設原來的值爲A,後來更新成了B,然後又更新成了A,這個時候去執行CAS的檢查操作時,內存中的值還是A,就會誤以爲內存中的值沒有變化,然後執行更新操作,實際上,這個時候內存中的值發生過變化。那麼怎麼解決ABA的問題呢?可以在每次更新值的時候添加一個版本號,那麼A->B->A就變爲了1A->2B->3A,這個時候就不會出現ABA的問題了。在JDK1.5開始,JUC包下提供了AtomicStampedReference類來解決ABA的問題。這個類的compareAndSet()方法會首先檢查當前引用是否等於預期的引用,然後檢查當前標誌是都等於預期標誌,如果都相等,纔會調用casPair()方法執行更新操作。casPair()方法最終也是調用了Unsafe類中的CAS方法。
  2. 性能問題。CAS會採用循環的方式來實現原子操作,如果長時間的循環設置不成功,就會一直佔用CPU,給CPU帶來很大的執行開銷,降低應用程序的性能。
  3. 只能保證一個共享變量的原子操作。對一個共享共享變量執行CAS操作時,能保證原子操作,但是如果同時對多個共享變量執行操作時,CAS就無法同時保證這多個共享變量的原子性。這個時候可以使用將多個共享變量封裝到一個對象中,然後使用JUC包下提供的AtomicReference類來實現原子操作。另外一種方案就是使用鎖。

4.2 內存操作

這部分主要包含堆外內存的分配、拷貝、釋放、給定地址值操作等方法。

//分配內存, 相當於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);

Unsafe能直接操作內存,它能直接進行申請內存、釋放內存、內存拷貝等操作。值得注意的是Unsafe直接申請的內存是堆外內存。何謂堆外內存呢?堆外是相對於JVM的內存來說的,通常我們應用程序運行後,創建的對象均在JVM內存中的堆中,堆內存的管理是JVM來管理的,而堆外內存指的是計算機中的直接內存,不受JVM管理。因此使用Unsafe類來申請對外內存時,要特別注意,否則容易出現內存泄漏等問題。

使用堆外內存的原因
  • 對垃圾回收停頓的改善。由於堆外內存是直接受操作系統管理而不是JVM,所以當我們使用堆外內存時,即可保持較小的堆內內存規模。從而在GC時減少回收停頓對於應用的影響。
  • 提升程序I/O操作的性能。通常在I/O通信過程中,會存在堆內內存到堆外內存的數據拷貝操作,對於需要頻繁進行內存間數據拷貝且生命週期較短的暫存數據,都建議存儲到堆外內存。
典型應用

Unsafe類對內存的操作在網絡通信框架中應用廣泛,如:Netty、MINA等通信框架。在java.nio包中的DirectByteBuffer中,內存的申請、釋放等邏輯都是調用Unsafe類中的對應方法來實現的。下面是DirectByteBuffer類的部分源碼。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-npSNGEBP-1577278059392)(https://p0.meituan.net/travelcube/5eb082d2e4baf2d993ce75747fc35de6486751.png)]

java.nio.ByteBuffer類是通過DirectByteBuffer類來操作內存,DirectByteBuffer又是通過Unsafe類來操作內存,所以最終實際上Netty對堆外的內存的操作是通過Unsafe類中的API來實現的。

4.3 線程調度

包括線程掛起、恢復、鎖機制等方法。

//取消阻塞線程
public native void unpark(Object thread);
//阻塞線程
public native void park(boolean isAbsolute, long time);
//獲得對象鎖(可重入鎖)
public native void monitorEnter(Object o);
//釋放對象鎖
public native void monitorExit(Object o);
//嘗試獲取對象鎖
public native boolean tryMonitorEnter(Object o);

​ 如上源碼說明中,方法park、unpark即可實現線程的掛起與恢復,將一個線程進行掛起是通過park方法實現的,調用park方法後,線程將一直阻塞直到超時或者中斷等條件出現;unpark可以終止一個掛起的線程,使其恢復正常。

​ Java鎖和同步器框架的核心類AbstractQueuedSynchronizer,就是通過調用LockSupport.park()LockSupport.unpark()實現線程的阻塞和喚醒的,而LockSupport的park、unpark方法實際是調用Unsafe的park、unpark方式來實現。下面是LockSupport類的部分源代碼。

public class LockSupport {    
    // UNSAFE是Unsafe類的實例
    public static void park() {
    	// 阻塞線程
        UNSAFE.park(false, 0L);
    }
    public static void unpark(Thread thread) {
        if (thread != null)
        	// 喚醒線程
            UNSAFE.unpark(thread);
    }
}

4.4 數組相關

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。後續相關原子性操作,均依賴於這兩個值進行數組中元素的定位。

public class AtomicIntegerArray implements java.io.Serializable {
    private static final long serialVersionUID = 2862133569453604235L;

    private static final Unsafe unsafe = Unsafe.getUnsafe();
    // 獲取數組中第一元素在內存中的偏移量
    private static final int base = unsafe.arrayBaseOffset(int[].class);
    private static final int shift;
    private final int[] array;

    static {
        // 獲取數組中每個元素佔用的內存大小
        // 對於int類型的元素,佔用的是4個字節大小,所以此時返回的是4
        int scale = unsafe.arrayIndexScale(int[].class);
        if ((scale & (scale - 1)) != 0)
            throw new Error("data type scale not a power of two");
        shift = 31 - Integer.numberOfLeadingZeros(scale);
    }

    private static long byteOffset(int i) {
        // 根據數組中第一個元素在內存中的偏移量和每個元素佔用的大小,
        // 計算出數組中第i個元素在內存中的偏移量
        return ((long) i << shift) + base;
    }
}

4.5 對象相關

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

//返回對象成員屬性在內存地址相對於此對象的內存地址的偏移量
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;
普通讀寫

通過Unsafe可以讀寫一個類的屬性,即使這個屬性是私有的,也可以對這個屬性進行讀寫。

讀寫一個Object屬性的相關方法

public native int getInt(Object var1, long var2);

public native void putInt(Object var1, long var2, int var4);

getInt用於從對象的指定偏移地址處讀取一個int。putInt用於在對象指定偏移地址處寫入一個int。其他的primitive type也有對應的方法。

Unsafe還可以直接在一個地址上讀寫

public native byte getByte(long var1);

public native void putByte(long var1, byte var3);

getByte用於從指定內存地址處開始讀取一個byte。putByte用於從指定內存地址寫入一個byte。其他的primitive type也有對應的方法。

volatile讀寫

普通的讀寫無法保證可見性和有序性,而volatile讀寫就可以保證可見性和有序性。volatile讀寫相對普通讀寫是更加昂貴的,因爲需要保證可見性和有序性,而與volatile寫入相比putOrderedXX寫入代價相對較低,putOrderedXX寫入不保證可見性,但是保證有序性,所謂有序性,就是保證指令不會重排序。

public native int getIntVolatile(Object var1, long var2);

public native void putIntVolatile(Object var1, long var2, int var4);

什麼是volatile語義?就是讀數據時每次都從內存中取最新的值,而不是使用CPU緩存中的值;存數據時將值立馬刷新到內存,而不是先寫到CPU緩存,等以後再刷新回內存。

有序寫入

有序寫入只保證寫入的有序性,不保證可見性,就是說一個線程的寫入不保證其他線程立馬可見。

public native void putOrderedObject(Object var1, long var2, Object var4);

public native void putOrderedInt(Object var1, long var2, int var4);

public native void putOrderedLong(Object var1, long var2, long var4);
objectFieldOffset()方法

對象相關操作的方法還有一個十分常用的方法:objectFieldOffset()。它的作用是獲取對象的某個非靜態字段相對於該對象的偏移地址,它與staticFieldOffset()的作用類似,但是存在一點區別。staticFieldOffset()獲取的是靜態字段相對於類對象(即類所對應的Class對象)的偏移地址。靜態字段存在於方法區中,靜態字段每次獲取的偏移量的值都是相同的。

objectFieldOffset()的應用場景十分廣泛,因爲在Unsafe類中,大部分API方法都需要傳入一個offset參數,這個參數表示的是偏移量,要想直接操作內存中某個地址的數據,就必須先找到這個數據在哪兒,而通過offset就能知道這個數據在哪兒。因此這個方法應用得十分廣泛。下面以AtomicInteger類爲例:在靜態代碼塊中,通過objectFieldOffset()獲取了value屬性在內存中的偏移量,這樣後面將value寫入到內存時,就能根據offset來寫入了。

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            // 在static靜態塊中調用objectFieldOffset()方法,獲取value字段在內存中的偏移量
            // 因爲後面AtomicInteger在進行原子操作時,需要調用Unsafe類的CAS方法,而這些方法均需要傳入offset這個參數
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
}
非常規實例化方法

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

//繞過構造方法、初始化代碼來創建對象
public native Object allocateInstance(Class<?> cls) throws InstantiationException;

4.6 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);

在JDK1.8開始,Java開始支持lambda表達式,而lambda表達式的實現是由字節碼指令invokedynimic和VM Anonymous Class模板機制來實現的,VM Anonymous Class模板機制最終會使用到Unsafe類的defineAnonymousClass()方法來創建匿名類。

4.7 內存屏障

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

loadFence:保證在這個屏障之前的所有讀操作都已經完成。
storeFence:保證在這個屏障之前的所有寫操作都已經完成。
fullFence:保證在這個屏障之前的所有讀寫操作都已經完成。

4.8 系統相關

//返回系統指針的大小。返回值爲4(32位系統)或 8(64位系統)。
public native int addressSize();  
//內存頁的大小,此值爲2的冪次方。
public native int pageSize();

這兩個方法在java.nio.Bits類中有實際應用。Bits作爲工具類,提供了計算所申請內存需要佔用多少內存頁的方法,這個時候需要知道硬件的內存頁大小,才能計算出佔用內存頁的數量。因此在這裏藉助了Unsafe.pageSize()方法來實現。Bits類的部分源碼如下。

class Bits { 
    static int pageSize() {
        if (pageSize == -1)
        	// 獲取內存頁大小
            pageSize = unsafe().pageSize();
        return pageSize;
    }

    // 根據內存大小,計算需要的內存頁數量
    static int pageCount(long size) {
        return (int)(size + (long)pageSize() - 1L) / pageSize();
    }  
}

五:收穫

  1. 基本瞭解了Unsafe類中的內容
  2. 對其和CAS操作相關的方法有進一步的瞭解,瞭解了一些併發編程中Unsafe的使用情況
class Bits { 
    static int pageSize() {
        if (pageSize == -1)
        	// 獲取內存頁大小
            pageSize = unsafe().pageSize();
        return pageSize;
    }

    // 根據內存大小,計算需要的內存頁數量
    static int pageCount(long size) {
        return (int)(size + (long)pageSize() - 1L) / pageSize();
    }  
}

五:收穫

  1. 基本瞭解了Unsafe類中的內容
  2. 對其和CAS操作相關的方法有進一步的瞭解,瞭解了一些併發編程中Unsafe的使用情況
  3. 回顧了一下反射知識,新學了native、volatile知識點
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章