當面試官問出“Unsafe”類時,我就知道這場面試廢了,祖墳都能給你問出來!

JavaBuild發表於2024-05-25

一、寫在開頭

依稀記得多年以前的一場面試中,面試官從Java併發程式設計問到了鎖,從鎖問到了原子性,從原子性問到了Atomic類庫(對著JUC包進行了刨根問底),從Atomic問到了CAS演算法,緊接著又有追問到了底層的Unsafe類,當問到Unsafe類時,我就知道這場面試廢了,這似乎把祖墳都能給問冒煙啊。

但時過境遷,現在再回想其那場面試,不再覺得面試官的追毛求疵,反而為那時候青澀菜雞的自己感到羞愧,為什麼這樣說呢,實事求是的說Unsafe類雖然是比較底層,並且我們日常開發不可能用到的類,但是!翻開JUC包中的很多工具類,只要底層用到了CAS思想來提升併發效能的,幾乎都脫離不了Unsafe類的運用,可惜那時候光知道被八股文了,沒有做到細心總結與發現。

二、Unsafe的基本介紹

我們知道C語言可以透過指標去操作記憶體空間,Java不存在指標,為了提升Java執行效率、增強Java語言底層資源操作能力,便誕生了Unsafe類,Unsafe是位於sun.misc包下。正如它的名字一樣,這種操作底層的方式是不安全的,在程式中過度和不合理的使用,會帶來未知的風險,因此,Unsafe雖然,但要慎用哦!

2.1 如何建立一個unsafe例項

我們無法直接透過new的方式建立一個unsafe的例項,為什麼呢?我們看它的這段原始碼便知:

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;
    }
  }
}

從原始碼中我們發現Unsafe類被final修飾,所以無法被繼承,同時它的無參構造方法被private修飾,也無法透過new去直接例項化,不過在Unsafe 類提供了一個靜態方法getUnsafe,看上去貌似可以用它來獲取 Unsafe 例項。但是!當我們直接去呼叫這個方法的時候,會報如下錯誤:

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類,到底怎樣才能獲取它的例項呢?

在這裡提供給大家兩種方式:

方式一

假若在A類中呼叫Unsafe例項,則可透過Java命令列命令-Xbootclasspath/a把呼叫Unsafe相關方法的類A所在jar包路徑追加到預設的bootstrap路徑中,使得A被啟動類載入器載入,從而透過Unsafe.getUnsafe方法安全的獲取Unsafe例項。

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

方式二

利用反射獲得 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;
 }

2.2 Unsafe的使用

上面我們已經知道了如何獲取一個unsafe例項了,那現在就開始寫一個小demo來感受一下它的使用吧。

public class TestService {
    //透過單例獲取例項
    public static Unsafe getUnsafe() throws IllegalAccessException, NoSuchFieldException {
        Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
        //Field unsafeField = Unsafe.class.getDeclaredFields()[0]; //也可以這樣,作用相同
        unsafeField.setAccessible(true);
        Unsafe unsafe =(Unsafe) unsafeField.get(null);
        return unsafe;
    }
    //呼叫例項方法去賦值
    public void fieldTest(Unsafe unsafe) throws NoSuchFieldException {
        Persion persion = new Persion();
        persion.setAge(10);
        System.out.println("ofigin_age:" + persion.getAge());
        long fieldOffset = unsafe.objectFieldOffset(Persion.class.getDeclaredField("age"));
        System.out.println("offset:"+fieldOffset);
        unsafe.putInt(persion,fieldOffset,20);
        System.out.println("new_age:"+unsafe.getInt(persion,fieldOffset));
    }

    public static void main(String[] args) {
        TestService testService = new TestService();
        try {
            testService.fieldTest(getUnsafe());
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}
class Persion{

    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

輸出:

ofigin_age:10
offset:12
new_age:20

透過 Unsafe 類的objectFieldOffset方法獲取到了物件中欄位的偏移地址,這個偏移地址不是記憶體中的絕對地址而是一個相對地址,之後再透過這個偏移地址對int型別欄位的屬性值進行讀寫操作,透過結果也可以看到 Unsafe 的方法和類中的get方法獲取到的值是相同的。

三、Unsafe類的8種應用

基於Unsafe所提供的API,我們大致可以將Unsafe根據應用場景分為如下的八類,上一個腦圖。
image

3.1 記憶體操作

學習過C或者C++的同學對於記憶體操作應該很熟悉了,在Java裡我們是無法直接對記憶體進行操作的,我們建立的物件幾乎都在堆內記憶體中存放,它的記憶體分配與管理都是JVM去實現,同時,在Java中還存在一個JVM管控之外的記憶體區域叫做“堆外記憶體”,Java中對堆外記憶體的操作,依賴於Unsafe提供的操作堆外記憶體的native方法啦。

記憶體操作的常用方法:

/*包含堆外記憶體的分配、複製、釋放、給定地址值操作*/
//分配記憶體, 相當於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);

在這裡我們不僅會想,為啥全是native方法呢?

  1. native方法透過JNI呼叫了其他語言,如果C++等提供的現車功能,可以讓Java拿來即用;
  2. 需要用到 Java 中不具備的依賴於作業系統的特性,Java 在實現跨平臺的同時要實現對底層的控制,需要藉助其他語言發揮作用;
  3. 程式對時間敏感或對效能要求非常高時,有必要使用更加底層的語言,例如 C/C++甚至是彙編。

【經典應用】
在Netty、MINA等NIO框架中我們常常會應到緩衝池,而實現緩衝池的一個重要類就是DirectByteBuffer,它主要的作用對於堆外記憶體的建立、使用、銷燬等工作。

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

image

image

從上圖我們可以看到,在構建例項時,DirectByteBuffer內部透過Unsafe.allocateMemory分配記憶體、Unsafe.setMemory進行記憶體初始化,而後構建Cleaner物件用於跟蹤DirectByteBuffer物件的垃圾回收,以實現當DirectByteBuffer被垃圾回收時,分配的堆外記憶體一起被釋放。

3.2 記憶體屏障

為了充分利用快取,提高程式的執行速度,編譯器在底層執行的時候,會進行指令重排序的最佳化操作,但這種最佳化,在有些時候會帶來 有序性 的問題。(在將volatile關鍵字的時候提到過了)

為了解決這一問題,Java中引入了記憶體屏障(Memory Barrier 又稱記憶體柵欄,是一個 CPU 指令),透過組織屏障兩邊的指令重排序從而避免編譯器和硬體的不正確最佳化情況。

在Unsafe類中提供了3個native方法來實現記憶體屏障:

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

【經典應用】
在之前的文章中,我們講過Java8中引入的一個高效能的讀寫鎖:StampedLock(鎖王),在這個鎖中同時支援悲觀讀與樂觀讀,悲觀讀就和ReentrantLock一致,樂觀讀中就使用到了unsafe的loadFence(),一起去看一下。

	/**
     * 使用樂觀讀鎖訪問共享資源
     * 注意:樂觀讀鎖在保證資料一致性上需要複製一份要操作的變數到方法棧,並且在運算元據時候					可能其他寫執行緒已經修改了資料,
     * 而我們操作的是方法棧裡面的資料,也就是一個快照,所以最多返回的不是最新的資料,但是一致性還是得到保障的。
     *
     * @return
     */
   double distanceFromOrigin() {
     long stamp = sl.tryOptimisticRead(); // 獲取樂觀讀鎖
     double currentX = x, currentY = y;	// 複製共享資源到本地方法棧中
     if (!sl.validate(stamp)) { // //檢查樂觀讀鎖後是否有其他寫鎖發生,有則返回false
        stamp = sl.readLock(); // 獲取一個悲觀讀鎖
        try {
          currentX = x;
          currentY = y;
        } finally {
           sl.unlockRead(stamp); // 釋放悲觀讀鎖
        }
     }
     return Math.sqrt(currentX * currentX + currentY * currentY);
   }

在官網給出的樂觀讀的使用案例中,我們看到if中做了一個根絕印章校驗寫鎖發生的操作,我們跟入這個校驗原始碼中:

public boolean validate(long stamp) {
        U.loadFence();//load記憶體屏障
        return (stamp & SBITS) == (state & SBITS);
    }

這一步的目的是防止鎖狀態校驗運算發生重排序導致鎖狀態校驗不準確的問題!

3.3 物件操作

其實在2.2 Unsafe的使用中,我們已經使用了Unsafe進行物件成員屬性的記憶體偏移量獲取,以及欄位屬性值的修改功能了,除了Int型別,Unsafe還支援對所有8種基本資料型別以及Object的記憶體資料修改,這裡就不再贅述了。

需要額外強掉的一點,在Unsafe的原始碼中還提供了一種非常規的方式進行物件的例項化:

//繞過構造方法、初始化程式碼來建立物件
public native Object allocateInstance(Class<?> cls) throws InstantiationException;

這種方法可以繞過構造方法和初始化程式碼塊來建立物件,我們寫一個小demo學習一下。

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

定義一個類A,我們分別採用無參構造器、newInstance()、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。這說明呼叫unsafe的allocateInstance方法確實可以跳過構造器去例項化物件!

3.4 陣列操作

在 Unsafe 中,可以使用arrayBaseOffset方法獲取陣列中第一個元素的偏移地址,使用arrayIndexScale方法可以獲取陣列中元素間的偏移地址增量,透過這兩個方法可以定位陣列中的每個元素在記憶體中的位置。

基於2.2 Unsafe使用的測試程式碼,我們增加如下的方法:

  //獲取陣列元素在記憶體中的偏移地址,以及偏移量
    private void arrayTest(Unsafe unsafe) {
        String[] array=new String[]{"aaa","bb","cc"};
        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 : aaa
20 : bb
24 : cc

3.5 CAS相關

終於,重點來了,我們寫這篇文章的初衷是什麼?是回想起曾經面時,面試官由原子類庫(Atomic)問到了CAS演算法,從而追問到了Unsafe類上,在JUC包中到處都可以看到CAS的身影,在java.util.concurrent.atomic相關類、Java AQS、CurrentHashMap等等類中均有!

以AtomicInteger為例,在內部提供了一個方法為compareAndSet(int expect, int update) ,如果輸入的數值等於預期值,則以原子方式將該值設定為輸入值(update),而它的底層呼叫則是unsafe的compareAndSwapInt()方法。

public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

CAS思想的底層實現其實就是Unsafe類中的幾個native本地方法:

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

3.6 執行緒排程

Unsafe 類中提供了park、unpark、monitorEnter、monitorExit、tryMonitorEnter方法進行執行緒排程,在前面介紹 AQS 的文章中我們學過,在AQS中透過呼叫LockSupport.park()和LockSupport.unpark()實現執行緒的阻塞和喚醒的,而LockSupport的park、unpark方法實際是呼叫Unsafe的park、unpark方式來實現。

//取消阻塞執行緒
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);

LockSupport原始碼:

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

3.7 Class操作

Unsafe 對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);

【測試案例】

@Data
 public class User {
     public static String name="javabuild";
     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);
 }

此外,在Java8中引入的Lambda表示式的實現中也使用到了defineClass和defineAnonymousClass方法。

3.8 系統資訊

Unsafe 中提供的addressSize和pageSize方法用於獲取系統資訊。

1) 呼叫addressSize方法會返回系統指標的大小,如果在 64 位系統下預設會返回 8,而 32 位系統則會返回 4。

2) 呼叫 pageSize 方法會返回記憶體頁的大小,值為 2 的整數冪。

使用下面的程式碼可以直接進行列印:

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

輸出為:8,4096

四、總結

哎呀,媽呀,終於寫完了,人要傻了,為了整理這篇文章看了大量的原始碼,人看的頭大,跟俄羅斯套娃似的原始碼,嚴謹的串聯在一起!Unsafe類在日常的面試中確實不經常被問到,大家稍微瞭解一下即可。

五、結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!
image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!
image

相關文章