CAS原子類:AtomicLongArray原始碼解析

狂盗一枝梅發表於2024-09-29

AtomicLongArray內部維護了一個int型別的陣列,需要先複習下陣列物件的在記憶體中的結構,這對接下來對陣列型別原子類的理解至關重要。

一、陣列物件的記憶體結構

我們執行以下程式碼並將陣列物件的記憶體結構透過JOL工具列印出來,關於這部分知識,參考之前的文章:深入理解Java物件結構

public class ArrayTest {
    
    public static void main(String[] args) {
        //列印虛擬機器資訊
        System.out.println(VM.current().details());
        //十個元素的int陣列
        int[] array = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
        //列印記憶體結構
        System.out.println(ClassLayout.parseInstance(array).toPrintable());
    }
}

輸出結果:

image-20240926145450620

整個物件大小是16位元組的物件頭+40位元組的物件體,一共56位元組,由於正好是對齊位元組數8的倍數,所以沒有對齊位元組。

問題來了,如果站在Unsafe類的角度上,如何實現快速訪問陣列中的某個元素?

Unsafe可以基於偏移量的快速訪問,也就是說只要告訴Unsafe前邊有多少個位元組數,它就可以直接定位到需要訪問的元素。如果我想訪問第1個元素,那前邊就只有物件頭,那就是16位元組;如果我想訪問第二個元素,那就是16位元組的物件頭+4位元組的第一個元素,就是20位元組;如果我想訪問呢第三個元素,那就是16位元組的物件頭+8位元組的兩個元素,就是24個位元組。。。如果我想訪問下標為i的元素,那就是(16+ix4)個位元組。

我們知道位移運算要比乘法運算效率高的多,為了效率最大化,可以使用位移運算替代乘法運算,4正好是22滿足位移計算替換的要求(熱知識:將一個數i向左位移N位,實際上等效於ix2N,所以必須保證第二個乘數是2的冪次方),所以16+ix4可以替換為16+i<<2,沒錯,在陣列型別原子類中,正是使用這種位移的方式快速定位元素的。

二、get方法原始碼解析

由於原始碼比較長,分開一點一點來看,先看get方法:public final int get(int i),說起來你肯定不信,這個方法是這個類最複雜的方法了

public class AtomicIntegerArray {

    //Unsafe類初始化
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    //陣列元素的記憶體偏移量
    private static final int base = unsafe.arrayBaseOffset(int[].class);
    //位移次數
    private static final int shift;
    //透過final保證可見性
    private final int[] array;

    static {
        //確定陣列中每個元素的大小,單位位元組
        int scale = unsafe.arrayIndexScale(int[].class);
        //確定scale大小是2的冪次方
        if ((scale & (scale - 1)) != 0)
            throw new Error("data type scale not a power of two");
        //確定位移次數,用於後續計算元素的記憶體偏移量
        shift = 31 - Integer.numberOfLeadingZeros(scale);
    }

    private long checkedByteOffset(int i) {
        if (i < 0 || i >= array.length)
            throw new IndexOutOfBoundsException("index " + i);

        return byteOffset(i);
    }

    //根據陣列座標查詢記憶體偏移量
    private static long byteOffset(int i) {
        return ((long) i << shift) + base;
    }

    //獲取某個元素的值
    public final int get(int i) {
        return getRaw(checkedByteOffset(i));
    }

    //呼叫unsafe方法根據偏移量獲取某個方法的值
    private int getRaw(long offset) {
        return unsafe.getIntVolatile(array, offset);
    }
}

上面的整個原始碼都是public final int get(int i)方法相關的函式和變數。接下來逐步看下每段程式碼的意思。

靜態程式碼塊

static {
    //確定陣列中每個元素的大小,單位位元組
    int scale = unsafe.arrayIndexScale(int[].class);
    //確定scale大小是2的冪次方
    if ((scale & (scale - 1)) != 0)
        throw new Error("data type scale not a power of two");
    //確定位移次數,用於後續計算元素的記憶體偏移量
    shift = 31 - Integer.numberOfLeadingZeros(scale);
}

相關的註釋在程式碼中,不再贅述,主要解答下幾個疑問

  • 為什麼要驗證scale大小是2的冪次方
  • Integer.numberOfLeadingZeros(scale)的函式作用是什麼
  • 為什麼要用31減去Integer.numberOfLeadingZeros(scale)
  • 最終計算出來的shift值是幹什麼用的

相信第一次見這段程式碼的人都和我一樣,對這段靜態程式碼塊中的每一行程式碼都有疑問😓,要解答這個問題,先去上面一章節複習下陣列物件的記憶體結構。

第一個問題:為什麼要驗證scale大小是2的冪次方?

因為之後要用到位移的方式快速計算元素偏移地址,如果不是2的冪次方,透過位移計算的元素偏移地址就會出錯。另外(scale & (scale - 1)) != 0這個程式碼用於快速校驗是否是2的冪次方,這個技巧基於以下原理:如果一個數是2的冪次方,那麼它的二進位制表示中只有最高位一個1,其餘位都是0。如果 scale 是2的冪次方,那麼 scale - 1 的二進位制表示中,除了最高位變成0外,其餘所有位都是1。假設 scale 是8,即 scale 的二進位制表示是 1000scale - 1 是7,即 scale - 1 的二進位制表示是 0111,當進行按位與操作 (8 & 7),結果就是是0。

第二個問題:Integer.numberOfLeadingZeros(scale)的函式作用是什麼

該方法用於計算整數值轉換為二進位制格式後,前導零的個數(即從最高位開始連續的0的個數)。這個方法可以幫助確定一個整數在二進位制表示中最高位1之前有多少個0。

第三個問題:shift值的作用是什麼

shift值實際上就是之前說的位移位數,16+i<<2 ,它就是這個公式中的2。31減去4的二進位制(100)的前導零的個數29(32-3=29),正好是2,因為位移運算計算的是次數,不包含4所在的二進位制位置,所以要用31減,實際上算出來的就是4的二進位制100中1後面的0的個數,這也側面解釋了為什麼scale必須是2的冪次方。

byteOffset方法

搞清楚了靜態程式碼塊,接下來看看計算偏移量byteOffset方法的實現。byteOffset方法是根據陣列座標查詢記憶體偏移量的方法。

//根據陣列座標查詢記憶體偏移量
private static long byteOffset(int i) {
    return ((long) i << shift) + base;
}

在上一個章節中,最後推匯出來一個結論:對於一個int型別的陣列,比如int[] array,使用偏移量定址的方式,可以使用i<<2 + 16的方式計算元素的偏移量,這裡的程式碼邏輯和我們之前的推導完全一致,因為以上的推導就是根據這個方法反推的😀。

get方法

最後看看get方法(OMG終於到了最終方法)

//呼叫unsafe方法根據偏移量獲取某個方法的值
private int getRaw(long offset) {
    return unsafe.getIntVolatile(array, offset);
}

前邊一長串都在計算offset偏移量的值,現在到了真正取值的地方了我反而有個疑惑:為什麼要大費周章的這麼取值,不能直接array+下標直接取嗎?

//透過final保證可見性
private final int[] array;

array變數被final修飾,具備了不可變性,同時也具備了可見性,所以unsafe類為什麼要getIntVolatile方法獲取元素值呢?從方法名字上來看該方法似乎有volatile關鍵字的語義,在這裡是用於保證可見性的。我覺得這個問題的原因在於使用 final 修飾陣列確實可以保證陣列引用的不可變性,即陣列引用不能被重新賦值,但是 final 修飾的陣列引用並不能保證陣列元素的不可變性,也不能保證陣列元素的可見性。

即使陣列被宣告為 final,在多執行緒環境下,如果一個執行緒修改了陣列中的元素,其他執行緒不一定能立即看到這個變化,這可能會導致執行緒之間的資料不一致性。

所以才要用unsafe類的getIntVolatile方法保證可見性(volatile關鍵字可不能加到陣列元素上)。

三、其它方法

public final long getAndSet(int i, long newValue);
public final boolean compareAndSet(int i, long expect, long update);
public final long getAndIncrement(int i);
public final long getAndDecrement(int i);
public final long getAndAdd(int i, long delta);
public final long incrementAndGet(int i);
public final long decrementAndGet(int i);
......

可以看到,這些方法和AtomicInteger基本上一模一樣,只是每個方法都多了一個i引數。

以上方法大同小異,底層都呼叫了getAndAdd方法

public final long getAndAdd(int i, long delta) {
    return unsafe.getAndAddLong(array, checkedByteOffset(i), delta);
}

可以看到,i就是陣列座標,最終用checkedByteOffset方法計算了偏移量。本質上和AtomicInteger是一模一樣的,詳情可參考:《CAS原子類:AtomicInteger原始碼解析》。

四、我的疑惑

AtomicLongArray類中有兩個方法

public final boolean compareAndSet(int i, long expect, long update) {
    return compareAndSetRaw(checkedByteOffset(i), expect, update);
}

public final boolean weakCompareAndSet(int i, long expect, long update) {
    return compareAndSet(i, expect, update);
}

這倆方法不是一模一樣的嗎,為啥AtomicInteger有的問題,在AtomicLongArray類中也有。。。



都看到這裡了,歡迎關注我的部落格呀(〃∀〃): https://blog.kdyzm.cn,歡迎到我的部落格留言:https://blog.kdyzm.cn/messageBoard

END.

相關文章