Java 雙刃劍之 Unsafe 類詳解

前一段時間在研究juc源碼的時候,發現在很多工具類中都調用了一個Unsafe類中的方法,出於好奇就想要研究一下這個類到底有什麼作用,於是先查閱了一些資料,一查不要緊,很多資料中對 Unsafe 的態度都是這樣的畫風:

其實看到這些說法也沒什麼意外,畢竟 Unsafe 這個詞直譯過來就是 “不安全的”,從名字裏我們也大概能看來 Java 的開發者們對它有些不放心。但是作爲一名極客,不能你說不安全我就不去研究了,畢竟只有瞭解一項技術的風險點,才能更好的避免出現這些問題嘛。

下面我們言歸正傳,先通過簡單的介紹來對 Unsafe 類有一個大致的瞭解。Unsafe 類是一個位於sun.misc包下的類,它提供了一些相對底層方法,能夠讓我們接觸到一些更接近操作系統底層的資源,如系統的內存資源、cpu 指令等。而通過這些方法,我們能夠完成一些普通方法無法實現的功能,例如直接使用偏移地址操作對象、數組等等。但是在使用這些方法提供的便利的同時,也存在一些潛在的安全因素,例如對內存的錯誤操作可能會引起內存泄漏,嚴重時甚至可能引起 jvm 崩潰。因此在使用 Unsafe 前,我們必須要了解它的工作原理與各方法的應用場景,並且在此基礎上仍需要非常謹慎的操作,下面我們正式開始對 Unsafe 的學習。

Unsafe 基礎

首先我們來嘗試獲取一個 Unsafe 實例,如果按照new的方式去創建對象,不好意思,編譯器會報錯提示你:

Unsafe() has private access in 'sun.misc.Unsafe'

查看 Unsafe 類的源碼,可以看到它被final修飾不允許被繼承,並且構造函數爲private類型,即不允許我們手動調用構造方法進行實例化,只有在static靜態代碼塊中,以單例的方式初始化了一個 Unsafe 對象:

public final class Unsafe {
    private static final Unsafe theUnsafe;
    ...
    private Unsafe() {
    }
    ...
    static {
        theUnsafe = new Unsafe();
    }   
}

在 Unsafe 類中,提供了一個靜態方法getUnsafe,看上去貌似可以用它來獲取 Unsafe 實例:

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

但是如果我們直接調用這個靜態方法,會拋出異常:

Exception in thread "main" java.lang.SecurityException: Unsafe
 at sun.misc.Unsafe.getUnsafe(Unsafe.java:90)
 at com.cn.test.GetUnsafeTest.main(GetUnsafeTest.java:12)

這是因爲在getUnsafe方法中,會對調用者的classLoader進行檢查,判斷當前類是否由Bootstrap classLoader加載,如果不是的話那麼就會拋出一個SecurityException異常。也就是說,只有啓動類加載器加載的類才能夠調用 Unsafe 類中的方法,來防止這些方法在不可信的代碼中被調用。

那麼,爲什麼要對 Unsafe 類進行這麼謹慎的使用限制呢,說到底,還是因爲它實現的功能過於底層,例如直接進行內存操作、繞過 jvm 的安全檢查創建對象等等,概括的來說,Unsafe 類實現功能可以被分爲下面 8 類:

創建實例

看到上面的這些功能,你是不是已經有些迫不及待想要試一試了。那麼如果我們執意想要在自己的代碼中調用 Unsafe 類的方法,應該怎麼獲取一個它的實例對象呢,答案是利用反射獲得 Unsafe 類中已經實例化完成的單例對象:

public static Unsafe getUnsafe() throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
    //Field unsafeField = Unsafe.class.getDeclaredFields()[0]; //也可以這樣,作用相同
    unsafeField.setAccessible(true);
    Unsafe unsafe =(Unsafe) unsafeField.get(null);
    return unsafe;
}

在獲取到 Unsafe 的實例對象後,我們就可以使用它爲所欲爲了,先來嘗試使用它對一個對象的屬性進行讀寫:

public void fieldTest(Unsafe unsafe) throws NoSuchFieldException {
    User user=new User();
    long fieldOffset = unsafe.objectFieldOffset(User.class.getDeclaredField("age"));
    System.out.println("offset:"+fieldOffset);
    unsafe.putInt(user,fieldOffset,20);
    System.out.println("age:"+unsafe.getInt(user,fieldOffset));
    System.out.println("age:"+user.getAge());
}

運行代碼輸出如下,可以看到通過 Unsafe 類的objectFieldOffset方法獲取了對象中字段的偏移地址,這個偏移地址不是內存中的絕對地址而是一個相對地址,之後再通過這個偏移地址對int類型字段的屬性值進行了讀寫操作,通過結果也可以看到 Unsafe 的方法和類中的get方法獲取到的值是相同的。

offset:12
age:20
age:20

在上面的例子中調用了 Unsafe 類的putIntgetInt方法,看一下源碼中的方法:

public native int getInt(Object o, long offset);
public native void putInt(Object o, long offset, int x);

先說作用,getInt用於從對象的指定偏移地址處讀取一個intputInt用於在對象指定偏移地址處寫入一個int,並且即使類中的這個屬性是private私有類型的,也可以對它進行讀寫。但是有細心的小夥伴可能發現了,這兩個方法相對於我們平常寫的普通方法,多了一個native關鍵字修飾,並且沒有具體的方法邏輯,那麼它是怎麼實現的呢?

native 方法

在 java 中,這類方法被稱爲native方法(Native Method),簡單的說就是由 java 調用非 java 代碼的接口,被調用的方法是由非 java 語言實現的,例如它可以由 C 或 C++ 語言來實現,並編譯成 DLL,然後直接供 java 進行調用。native方法是通過 JNI(Java Native Interface)實現調用的,從 java1.1 開始 JNI 標準就是 java 平臺的一部分,它允許 java 代碼和其他語言的代碼進行交互。

Unsafe 類中的很多基礎方法都屬於native方法,那麼爲什麼要使用native方法呢?原因可以概括爲以下幾點:

juc包的很多併發工具類在實現併發機制時,都調用了native方法,通過它們打破了 java 運行時的界限,能夠接觸到操作系統底層的某些功能。對於同一個native方法,不同的操作系統可能會通過不同的方式來實現,但是對於使用者來說是透明的,最終都會得到相同的結果,至於 java 如何實現的通過 JNI 調用其他語言的代碼,不是本文的重點,會在後續的文章中具體學習。

Unsafe 應用

在對 Unsafe 的基礎有了一定了解後,我們來看一下它的基本應用。由於篇幅有限,不能對所有方法進行介紹,如果大家有學習的需要,可以下載 openJDK 的源碼進行學習。

1、內存操作

如果你是一個寫過c或者c++的程序員,一定對內存操作不會陌生,而在 java 中是不允許直接對內存進行操作的,對象內存的分配和回收都是由jvm自己實現的。但是在 Unsafe 中,提供的下列接口可以直接進行內存操作:

//分配新的本地空間
public native long allocateMemory(long bytes);
//重新調整內存空間的大小
public native long reallocateMemory(long address, long bytes);
//將內存設置爲指定值
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);
//清除內存
public native void freeMemory(long address);

使用下面的代碼進行測試:

private void memoryTest() {
    int size = 4;
    long addr = unsafe.allocateMemory(size);
    long addr3 = unsafe.reallocateMemory(addr, size * 2);
    System.out.println("addr: "+addr);
    System.out.println("addr3: "+addr3);
    try {
        unsafe.setMemory(null,addr ,size,(byte)1);
        for (int i = 0; i < 2; i++) {
            unsafe.copyMemory(null,addr,null,addr3+size*i,4);
        }
        System.out.println(unsafe.getInt(addr));
        System.out.println(unsafe.getLong(addr3));
    }finally {
        unsafe.freeMemory(addr);
        unsafe.freeMemory(addr3);
    }
}

先看結果輸出:

addr: 2433733895744
addr3: 2433733894944
16843009
72340172838076673

分析一下運行結果,首先使用allocateMemory方法申請 4 字節長度的內存空間,在循環中調用setMemory方法向每個字節寫入內容爲byte類型的 1,當使用 Unsafe 調用getInt方法時,因爲一個int型變量佔 4 個字節,會一次性讀取 4 個字節,組成一個int的值,對應的十進制結果爲 16843009,可以通過圖示理解這個過程:

在代碼中調用reallocateMemory方法重新分配了一塊 8 字節長度的內存空間,通過比較addraddr3可以看到和之前申請的內存地址是不同的。在代碼中的第二個 for 循環裏,調用copyMemory方法進行了兩次內存的拷貝,每次拷貝內存地址addr開始的 4 個字節,分別拷貝到以addr3addr3+4開始的內存空間上:

拷貝完成後,使用getLong方法一次性讀取 8 個字節,得到long類型的值爲 72340172838076673。

需要注意,通過這種方式分配的內存屬於堆外內存,是無法進行垃圾回收的,需要我們把這些內存當做一種資源去手動調用freeMemory方法進行釋放,否則會產生內存泄漏。通用的操作內存方式是在try中執行對內存的操作,最終在finally塊中進行內存的釋放。

2、內存屏障

在介紹內存屏障前,需要知道編譯器和 CPU 會在保證程序輸出結果一致的情況下,會對代碼進行重排序,從指令優化角度提升性能。而指令重排序可能會帶來一個不好的結果,導致 CPU 的高速緩存和內存中數據的不一致,而內存屏障(Memory Barrier)就是通過組織屏障兩邊的指令重排序從而避免編譯器和硬件的不正確優化情況。

在硬件層面上,內存屏障是 CPU 爲了防止代碼進行重排序而提供的指令,不同的硬件平臺上實現內存屏障的方法可能並不相同。在 java8 中,引入了 3 個內存屏障的函數,它屏蔽了操作系統底層的差異,允許在代碼中定義、並統一由 jvm 來生成內存屏障指令,來實現內存屏障的功能。Unsafe 中提供了下面三個內存屏障相關方法:

//禁止讀操作重排序
public native void loadFence();
//禁止寫操作重排序
public native void storeFence();
//禁止讀、寫操作重排序
public native void fullFence();

內存屏障可以看做對內存隨機訪問的操作中的一個同步點,使得此點之前的所有讀寫操作都執行後纔可以開始執行此點之後的操作。以loadFence方法爲例,它會禁止讀操作重排序,保證在這個屏障之前的所有讀操作都已經完成,並且將緩存數據設爲無效,重新從主存中進行加載。

看到這估計很多小夥伴們會想到volatile關鍵字了,如果在字段上添加了volatile關鍵字,就能夠實現字段在多線程下的可見性。基於讀內存屏障,我們也能實現相同的功能。下面定義一個線程方法,在線程中去修改flag標誌位,注意這裏的flag是沒有被volatile修飾的:

@Getter
class ChangeThread implements Runnable{
    /**volatile**/ boolean flag=false;
    @Override
    public void run() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }        
        System.out.println("subThread change flag to:" + flag);
        flag = true;
    }
}

在主線程的while循環中,加入內存屏障,測試是否能夠感知到flag的修改變化:

public static void main(String[] args){
    ChangeThread changeThread = new ChangeThread();
    new Thread(changeThread).start();
    while (true) {
        boolean flag = changeThread.isFlag();
        unsafe.loadFence(); //加入讀內存屏障
        if (flag){
            System.out.println("detected flag changed");
            break;
        }
    }
    System.out.println("main thread end");
}

運行結果:

subThread change flag to:false
detected flag changed
main thread end

而如果刪掉上面代碼中的loadFence方法,那麼主線程將無法感知到flag發生的變化,會一直在while中循環。可以用圖來表示上面的過程:

瞭解 java 內存模型(JMM)的小夥伴們應該清楚,運行中的線程不是直接讀取主內存中的變量的,只能操作自己工作內存中的變量,然後同步到主內存中,並且線程的工作內存是不能共享的。上面的圖中的流程就是子線程藉助於主內存,將修改後的結果同步給了主線程,進而修改主線程中的工作空間,跳出循環。

3、對象操作

a、對象成員屬性的內存偏移量獲取,以及字段屬性值的修改,在上面的例子中我們已經測試過了。除了前面的putIntgetInt方法外,Unsafe 提供了全部 8 種基礎數據類型以及Objectputget方法,並且所有的put方法都可以越過訪問權限,直接修改內存中的數據。閱讀 openJDK 源碼中的註釋發現,基礎數據類型和Object的讀寫稍有不同,基礎數據類型是直接操作的屬性值(value),而Object的操作則是基於引用值(reference value)。下面是Object的讀寫方法:

//在對象的指定偏移地址獲取一個對象引用
public native Object getObject(Object o, long offset);
//在對象指定偏移地址寫入一個對象引用
public native void putObject(Object o, long offset, Object x);

除了對象屬性的普通讀寫外,Unsafe 還提供了 volatile 讀寫有序寫入方法。volatile讀寫方法的覆蓋範圍與普通讀寫相同,包含了全部基礎數據類型和Object類型,以int類型爲例:

//在對象的指定偏移地址處讀取一個int值,支持volatile load語義
public native int getIntVolatile(Object o, long offset);
//在對象指定偏移地址處寫入一個int,支持volatile store語義
public native void putIntVolatile(Object o, long offset, int x);

相對於普通讀寫來說,volatile讀寫具有更高的成本,因爲它需要保證可見性和有序性。在執行get操作時,會強制從主存中獲取屬性值,在使用put方法設置屬性值時,會強制將值更新到主存中,從而保證這些變更對其他線程是可見的。

有序寫入的方法有以下三個:

public native void putOrderedObject(Object o, long offset, Object x);
public native void putOrderedInt(Object o, long offset, int x);
public native void putOrderedLong(Object o, long offset, long x);

有序寫入的成本相對volatile較低,因爲它只保證寫入時的有序性,而不保證可見性,也就是一個線程寫入的值不能保證其他線程立即可見。爲了解決這裏的差異性,需要對內存屏障的知識點再進一步進行補充,首先需要了解兩個指令的概念:

順序寫入與volatile寫入的差別在於,在順序寫時加入的內存屏障類型爲StoreStore類型,而在volatile寫入時加入的內存屏障是StoreLoad類型,如下圖所示:

在有序寫入方法中,使用的是StoreStore屏障,該屏障確保Store1立刻刷新數據到內存,這一操作先於Store2以及後續的存儲指令操作。而在volatile寫入中,使用的是StoreLoad屏障,該屏障確保Store1立刻刷新數據到內存,這一操作先於Load2及後續的裝載指令,並且,StoreLoad屏障會使該屏障之前的所有內存訪問指令,包括存儲指令和訪問指令全部完成之後,才執行該屏障之後的內存訪問指令。

綜上所述,在上面的三類寫入方法中,在寫入效率方面,按照putputOrderputVolatile的順序效率逐漸降低,

b、使用 Unsafe 的allocateInstance方法,允許我們使用非常規的方式進行對象的實例化,首先定義一個實體類,並且在構造函數中對其成員變量進行賦值操作:

@Data
public class A {
    private int b;
    public A(){
        this.b =1;
    }
}

分別基於構造函數、反射以及 Unsafe 方法的不同方式創建對象進行比較:

public void objTest() throws Exception{
    A a1=new A();
    System.out.println(a1.getB());
    A a2 = A.class.newInstance();
    System.out.println(a2.getB());
    A a3= (A) unsafe.allocateInstance(A.class);
    System.out.println(a3.getB());
}

打印結果分別爲 1、1、0,說明通過allocateInstance方法創建對象過程中,不會調用類的構造方法。使用這種方式創建對象時,只用到了Class對象,所以說如果想要跳過對象的初始化階段或者跳過構造器的安全檢查,就可以使用這種方法。在上面的例子中,如果將 A 類的構造函數改爲private類型,將無法通過構造函數和反射創建對象,但allocateInstance方法仍然有效。

4、數組操作

在 Unsafe 中,可以使用arrayBaseOffset方法可以獲取數組中第一個元素的偏移地址,使用arrayIndexScale方法可以獲取數組中元素間的偏移地址增量。使用下面的代碼進行測試:

private void arrayTest() {
    String[] array=new String[]{"str1str1str","str2","str3"};
    int baseOffset = unsafe.arrayBaseOffset(String[].class);
    System.out.println(baseOffset);
    int scale = unsafe.arrayIndexScale(String[].class);
    System.out.println(scale);

    for (int i = 0; i < array.length; i++) {
        int offset=baseOffset+scale*i;
        System.out.println(offset+" : "+unsafe.getObject(array,offset));
    }
}

上面代碼的輸出結果爲:

16
4
16 : str1str1str
20 : str2
24 : str3

通過配合使用數組偏移首地址和各元素間偏移地址的增量,可以方便的定位到數組中的元素在內存中的位置,進而通過getObject方法直接獲取任意位置的數組元素。需要說明的是,arrayIndexScale獲取的並不是數組中元素佔用的大小,而是地址的增量,按照 openJDK 中的註釋,可以將它翻譯爲元素尋址的轉換因子scale factor for addressing elements)。在上面的例子中,第一個字符串長度爲 11 字節,但其地址增量仍然爲 4 字節。

那麼,基於這兩個值是如何實現的尋址和數組元素的訪問呢,這裏需要藉助一點在前面的文章中講過的 Java 對象內存佈局的知識,先把上面例子中的 String 數組對象的內存佈局畫出來,就很方便大家理解了:

在 String 數組對象中,對象頭包含 3 部分,mark word標記字佔用 8 字節,klass point類型指針佔用 4 字節,數組對象特有的數組長度部分佔用 4 字節,總共佔用了 16 字節。第一個 String 的引用類型相對於對象的首地址的偏移量是就 16,之後每個元素在這個基礎上加 4,正好對應了我們上面代碼中的尋址過程,之後再使用前面說過的getObject方法,通過數組對象可以獲得對象在堆中的首地址,再配合對象中變量的偏移量,就能獲得每一個變量的引用。

5、CAS 操作

juc包的併發工具類中大量地使用了 CAS 操作,像在前面介紹synchronizedAQS的文章中也多次提到了 CAS,其作爲樂觀鎖在併發工具類中廣泛發揮了作用。在 Unsafe 類中,提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法來實現的對Objectintlong類型的 CAS 操作。以compareAndSwapInt方法爲例:

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

參數中o爲需要更新的對象,offset是對象o中整形字段的偏移量,如果這個字段的值與expected相同,則將字段的值設爲x這個新值,並且此更新是不可被中斷的,也就是一個原子操作。下面是一個使用compareAndSwapInt的例子:

private volatile int a;
public static void main(String[] args){
    CasTest casTest=new CasTest();
    new Thread(()->{
        for (int i = 1; i < 5; i++) {
            casTest.increment(i);
            System.out.print(casTest.a+" ");
        }
    }).start();
    new Thread(()->{
        for (int i = 5 ; i <10 ; i++) {
            casTest.increment(i);
            System.out.print(casTest.a+" ");
        }
    }).start();
}

private void increment(int x){
    while (true){
        try {
            long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a"));
            if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x))
                break;
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
    }
}

運行代碼會依次輸出:

1 2 3 4 5 6 7 8 9

在上面的例子中,使用兩個線程去修改int型屬性a的值,並且只有在a的值等於傳入的參數x減一時,纔會將a的值變爲x,也就是實現對a的加一的操作。流程如下所示:

需要注意的是,在調用compareAndSwapInt方法後,會直接返回truefalse的修改結果,因此需要我們在代碼中手動添加自旋的邏輯。在AtomicInteger類的設計中,也是採用了將compareAndSwapInt的結果作爲循環條件,直至修改成功才退出死循環的方式來實現的原子性的自增操作。

6、線程調度

Unsafe 類中提供了parkunparkmonitorEntermonitorExittryMonitorEnter方法進行線程調度,在前面介紹 AQS 的文章中我們提到過使用LockSupport掛起或喚醒指定線程,看一下LockSupport的源碼,可以看到它也是調用的 Unsafe 類中的方法:

public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    UNSAFE.park(false, 0L);
    setBlocker(t, null);
}
public static void unpark(Thread thread) {
    if (thread != null)
        UNSAFE.unpark(thread);
}

LockSupport 的park方法調用了 Unsafe 的park方法來阻塞當前線程,此方法將線程阻塞後就不會繼續往後執行,直到有其他線程調用unpark方法喚醒當前線程。下面的例子對 Unsafe 的這兩個方法進行測試:

public static void main(String[] args) {
    Thread mainThread = Thread.currentThread();
    new Thread(()->{
        try {
            TimeUnit.SECONDS.sleep(5);
            System.out.println("subThread try to unpark mainThread");
            unsafe.unpark(mainThread);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    System.out.println("park main mainThread");
    unsafe.park(false,0L);
    System.out.println("unpark mainThread success");
}

程序輸出爲:

park main mainThread
subThread try to unpark mainThread
unpark mainThread success

程序運行的流程也比較容易看懂,子線程開始運行後先進行睡眠,確保主線程能夠調用park方法阻塞自己,子線程在睡眠 5 秒後,調用unpark方法喚醒主線程,使主線程能繼續向下執行。整個流程如下圖所示:

此外,Unsafe 源碼中monitor相關的三個方法已經被標記爲deprecated,不建議被使用:

//獲得對象鎖
@Deprecated
public native void monitorEnter(Object var1);
//釋放對象鎖
@Deprecated
public native void monitorExit(Object var1);
//嘗試獲得對象鎖
@Deprecated
public native boolean tryMonitorEnter(Object var1);

monitorEnter方法用於獲得對象鎖,monitorExit用於釋放對象鎖,如果對一個沒有被monitorEnter加鎖的對象執行此方法,會拋出IllegalMonitorStateException異常。tryMonitorEnter方法嘗試獲取對象鎖,如果成功則返回true,反之返回false

7、Class 操作

Unsafe 對Class的相關操作主要包括類加載和靜態變量的操作方法。

a、靜態屬性讀取相關的方法:

//獲取靜態屬性的偏移量
public native long staticFieldOffset(Field f);
//獲取靜態屬性的對象指針
public native Object staticFieldBase(Field f);
//判斷類是否需要實例化(用於獲取類的靜態屬性前進行檢測)
public native boolean shouldBeInitialized(Class<?> c);

創建一個包含靜態屬性的類,進行測試:

@Data
public class User {
    public static String ;
    int age;
}
private void staticTest() throws Exception {
    User user=new User();
    System.out.println(unsafe.shouldBeInitialized(User.class));
    Field sexField = User.class.getDeclaredField("name");
    long fieldOffset = unsafe.staticFieldOffset(sexField);
    Object fieldBase = unsafe.staticFieldBase(sexField);
    Object object = unsafe.getObject(fieldBase, fieldOffset);
    System.out.println(object);
}

運行結果:

false
Hydra

在 Unsafe 的對象操作中,我們學習了通過objectFieldOffset方法獲取對象屬性偏移量並基於它對變量的值進行存取,但是它不適用於類中的靜態屬性,這時候就需要使用staticFieldOffset方法。在上面的代碼中,只有在獲取Field對象的過程中依賴到了Class,而獲取靜態變量的屬性時不再依賴於Class

在上面的代碼中首先創建一個User對象,這是因爲如果一個類沒有被實例化,那麼它的靜態屬性也不會被初始化,最後獲取的字段屬性將是null。所以在獲取靜態屬性前,需要調用shouldBeInitialized方法,判斷在獲取前是否需要初始化這個類。如果刪除創建 User 對象的語句,運行結果會變爲:

true
null

b、使用defineClass方法允許程序在運行時動態地創建一個類,方法定義如下:

public native Class<?> defineClass(String name, byte[] b, int off, int len,
                                   ClassLoader loader,ProtectionDomain protectionDomain);

在實際使用過程中,可以只傳入字節數組、起始字節的下標以及讀取的字節長度,默認情況下,類加載器(ClassLoader)和保護域(ProtectionDomain)來源於調用此方法的實例。下面的例子中實現了反編譯生成後的 class 文件的功能:

private static void defineTest() {
    String fileName="F:\\workspace\\unsafe-test\\target\\classes\\com\\cn\\model\\User.class";
    File file = new File(fileName);
    try(FileInputStream fis = new FileInputStream(file)) {
        byte[] content=new byte[(int)file.length()];
        fis.read(content);
        Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null);
        Object o = clazz.newInstance();
        Object age = clazz.getMethod("getAge").invoke(o, null);
        System.out.println(age);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

在上面的代碼中,首先讀取了一個class文件並通過文件流將它轉化爲字節數組,之後使用defineClass方法動態的創建了一個類,並在後續完成了它的實例化工作,流程如下圖所示,並且通過這種方式創建的類,會跳過 JVM 的所有安全檢查。

除了defineClass方法外,Unsafe 還提供了一個defineAnonymousClass方法:

public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);

使用該方法可以用來動態的創建一個匿名類,在Lambda表達式中就是使用 ASM 動態生成字節碼,然後利用該方法定義實現相應的函數式接口的匿名類。在 jdk15 發佈的新特性中,在隱藏類(Hidden classes)一條中,指出將在未來的版本中棄用 Unsafe 的defineAnonymousClass方法。

8、系統信息

Unsafe 中提供的addressSizepageSize方法用於獲取系統信息,調用addressSize方法會返回系統指針的大小,如果在 64 位系統下默認會返回 8,而 32 位系統則會返回 4。調用 pageSize 方法會返回內存頁的大小,值爲 2 的整數冪。使用下面的代碼可以直接進行打印:

private void systemTest() {
    System.out.println(unsafe.addressSize());
    System.out.println(unsafe.pageSize());
}

執行結果:

8
4096

這兩個方法的應用場景比較少,在java.nio.Bits類中,在使用pageCount計算所需的內存頁的數量時,調用了pageSize方法獲取內存頁的大小。另外,在使用copySwapMemory方法拷貝內存時,調用了addressSize方法,檢測 32 位系統的情況。

總結

在本文中,我們首先介紹了 Unsafe 的基本概念、工作原理,並在此基礎上,對它的 API 進行了說明與實踐。相信大家通過這一過程,能夠發現 Unsafe 在某些場景下,確實能夠爲我們提供編程中的便利。但是回到開頭的話題,在使用這些便利時,確實存在着一些安全上的隱患,在我看來,一項技術具有不安全因素並不可怕,可怕的是它在使用過程中被濫用。儘管之前有傳言說會在 java9 中移除 Unsafe 類,不過它還是照樣已經存活到了 jdk16,按照存在即合理的邏輯,只要使用得當,它還是能給我們帶來不少的幫助,因此最後還是建議大家,在使用 Unsafe 的過程中一定要做到使用謹慎使用、避免濫用。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sgA-DfyCcDBC7iJ_QOjeug