Java雙刃劍之Unsafe類詳解

碼農參上發表於2021-04-30

前一段時間在研究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方法呢?原因可以概括為以下幾點:

  • 需要用到 java 中不具備的依賴於作業系統的特性,java在實現跨平臺的同時要實現對底層的控制,需要藉助其他語言發揮作用
  • 對於其他語言已經完成的一些現成功能,可以使用java直接呼叫
  • 程式對時間敏感或對效能要求非常高時,有必要使用更加底層的語言,例如C/C++甚至是彙編

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較低,因為它只保證寫入時的有序性,而不保證可見性,也就是一個執行緒寫入的值不能保證其他執行緒立即可見。為了解決這裡的差異性,需要對記憶體屏障的知識點再進一步進行補充,首先需要了解兩個指令的概念:

  • Load:將主記憶體中的資料拷貝到處理器的快取中
  • Store:將處理器快取的資料重新整理到主記憶體中

順序寫入與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 name="Hydra";
    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的過程中一定要做到使用謹慎使用、避免濫用。

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

相關文章