構建高效能佇列,你不得不知道的底層知識!

彤哥讀原始碼發表於2020-08-15

前言

本文收錄於專輯:http://dwz.win/HjK,點選解鎖更多資料結構與演算法的知識。

你好,我是彤哥。

上一節,我們一起學習瞭如何將遞迴改寫為非遞迴,其中,用到的資料結構主要是棧。

棧和佇列,可以說是除了陣列和連結串列之外最基礎的資料結構了,在很多場景中都有用到,後面我們也會陸陸續續的看到。

今天,我想介紹一下,在Java中,如何構建一個高效能的佇列,以及我們需要掌握的底層知識。

學習其他語言的同學,也可以看看,在你的語言中,是如何構建高效能佇列的。

佇列

佇列,是一種先進先出(First In First Out,FIFO)的資料結構,類似於實際生活場景中的排隊,先到的人先得。

file

使用陣列和連結串列實現簡單的佇列,我們前面都介紹過了,這裡就不再贅述了,有興趣的同學可以點選以下連結檢視:

重溫四大基礎資料結構:陣列、連結串列、佇列和棧

今天我們主要來學習如何實現高效能的佇列。

說起高效能的佇列,當然是說在高併發環境下也能夠工作得很好的佇列,這裡的很好主要是指兩個方面:併發安全、效能好。

併發安全的佇列

在Java中,預設地,也自帶了一些併發安全的佇列:

佇列 有界性 資料結構
ArrayBlockingQueue 有界 加鎖 陣列
LinkedBlockingQueue 可選有界 加鎖 連結串列
ConcurrentLinkedQueue 無界 無鎖 連結串列
SynchronousQueue 無界 無鎖 佇列或棧
LinkedTransferQueue 無界 無鎖 連結串列
PriorityBlockingQueue 無界 加鎖
DelayQueue 無界 加鎖

這些佇列的原始碼解析快捷入口:死磕 Java併發集合之終結篇

總結起來,實現併發安全佇列的資料結構主要有:陣列、連結串列和堆,堆主要用於實現優先順序佇列,不具備通用性,暫且不討論。

從有界性來看,只有ArrayBlockingQueue和LinkedBlockingQueue可以實現有界佇列,其它的都是無界佇列。

從加鎖來看,ArrayBlockingQueue和LinkedBlockingQueue都採用了加鎖的方式,其它的都是採用的CAS這種無鎖的技術實現的。

從安全性的角度來說,我們一般都要選擇有界佇列,防止生產者速度過快導致記憶體溢位。

從效能的角度來說,我們一般要考慮無鎖的方式,減少執行緒上下文切換帶來的效能損耗。

從JVM的角度來說,我們一般選擇陣列的實現方式,因為連結串列會頻繁的增刪節點,導致頻繁的垃圾回收,這也是一種效能損耗。

所以,最佳的選擇就是:陣列 + 有界 + 無鎖。

而JDK並沒有提供這樣的佇列,因此,很多開源框架都自己實現了高效能的佇列,比如Disruptor,以及Netty中使用的jctools。

高效能佇列

我們這裡不討論具體的某一個框架,只介紹實現高效能佇列的通用技術,並自己實現一個。

環形陣列

通過上面的討論,我們知道實現高效能佇列使用的資料結構只能是陣列,而陣列實現佇列,必然要使用到環形陣列。

環形陣列,一般通過設定兩個指標實現:putIndex和takeIndex,或者叫writeIndex和readIndex,一個用於寫,一個用於讀。

file

當寫指標到達陣列尾端時,會從頭開始,當然,不能越過讀指標,同理,讀指標到達陣列尾端時,也會從頭開始,當然,不能讀取未寫入的資料。

file

而為了防止寫指標和讀指標重疊的時候,無法分清佇列到底是滿了還是空的狀態,一般會再新增一個size欄位:

file

file

所以,使用環形陣列實現佇列的資料結構一般為:

public class ArrayQueue<T> {
    private T[] array;
    private long wrtieIndex;
    private long readIndex;
    private long size;
}

在單執行緒的情況下,這樣不會有任何問題,但是,在多執行緒環境中,這樣會帶來嚴重的偽共享問題。

偽共享

什麼是共享?

在計算機中,有很多儲存單元,我們接觸最多的就是記憶體,又叫做主記憶體,此外,CPU還有三級快取:L1、L2、L3,L1最貼近CPU,當然,它的儲存空間也很小,L2比L1稍大一些,L3最大,可以同時快取多個核心的資料。CPU取資料的時候,先從L1快取中讀取,如果沒有再從L2快取中讀取,如果沒有再從L3中讀取,如果三級快取都沒有,最後會從記憶體中讀取。離CPU核心越遠,則相對的耗時就越長,所以,如果要做一些很頻繁的操作,要儘量保證資料快取在L1中,這樣能極大地提高效能。

file

快取行

而資料在三級快取中,也不是說來一個資料快取一下,而是一次快取一批資料,這一批資料又稱作快取行(Cache Line),通常為64位元組。

file

每一次,當CPU去記憶體中拿資料的時候,都會把它後面的資料一併拿過來(組成64位元組),我們以long型陣列為例,當CPU取陣列中一個long的時候,同時會把後續的7個long一起取到快取行中。

file

這在一定程度上能夠加快資料的處理,因為,此時在處理下標為0的資料,下一個時刻可能就要處理下標為1的資料了,直接從快取中取要快很多。

但是,這樣又帶來了一個新的問題——偽共享。

偽共享

試想一下,兩個執行緒(CPU)同時在處理這個陣列中的資料,兩個CPU都快取了,一個CPU在對array[0]的資料加1,另一個CPU在對array[1]的資料加1,那麼,回寫到主記憶體的時候,到底以哪個快取行的資料為準(寫回主記憶體的時候也是以快取行的形式寫回),所以,此時,就需要對這兩個快取行“加鎖”了,一個CPU先修改資料,寫回主記憶體,另一個CPU才能讀取資料並修改資料,再寫回主記憶體,這樣勢必會帶來效能的損耗,出現的這種現象就叫做偽共享,這種“加鎖”的方式叫做記憶體屏障,關於記憶體屏障的知識我們就不展開敘述了。

那麼,怎麼解決偽共享帶來的問題呢?

以環形陣列實現的佇列為例,writeIndex、readIndex、size現在是這樣處理的:

file

所以,我們只需要在writeIndex和readIndex之間加7個long就可以把它們隔離開,同理,readIndex和size之間也是一樣的。

file

這樣就消除了writeIndex和readIndex之間的偽共享問題,因為writeIndex和readIndex肯定是在兩個不同的執行緒中更新,所以,消除偽共享之後帶來的效能提升是很明顯的。

假如有多個生產者,writeIndex是肯定會被爭用的,此時,要怎麼友好地修改writeIndex呢?即一個生產者執行緒修改了writeIndex,另一個生產者執行緒要立馬可見。

你第一時間想到的肯定是volatile,沒錯,可是光volatile還不行哦,volatile只能保證可見性和有序性,不能保證原子性,所以,還需要加上原子指令CAS,CAS是誰提供的?原子類AtomicInteger和AtomicLong都具有CAS的功能,那我們直接使用他們嗎?肯定不是,仔細觀察,發現他們最終都是呼叫Unsafe實現的。

OK,下面就輪到最牛逼的底層殺手登場了——Unsafe。

Unsafe

Unsafe不僅提供了CAS的指令,還提供很多其它操作底層的方法,比如操作直接記憶體、修改私有變數的值、例項化一個類、阻塞/喚醒執行緒、帶有記憶體屏障的方法等。

關於Unsafe,可以看這篇文章:死磕 java魔法類之Unsafe解析

當然,構建高效能佇列,主要使用的是Unsafe的CAS指令以及帶有記憶體屏障的方法等:

// 原子指令
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
// 以volatile的形式獲取值,相當於給變數加了volatile關鍵字
public native long getLongVolatile(Object var1, long var2);
// 延遲更新,對變數的修改不會立即寫回到主記憶體,也就是說,另一個執行緒不會立即可見
public native void putOrderedLong(Object var1, long var2, long var4);

好了,底層知識介紹的差不多了,是時候展現真正的技術了——手寫高效能佇列。

手寫高效能佇列

我們假設這樣一種場景:有多個生產者(Multiple Producer),卻只有一個消費者(Single Consumer),這是Netty中的經典場景,這樣一種佇列該怎麼實現?

直接上程式碼:

/**
 * 多生產者單消費者佇列
 *
 * @param <T>
 */
public class MpscArrayQueue<T> {

    long p01, p02, p03, p04, p05, p06, p07;
    // 存放元素的地方
    private T[] array;
    long p1, p2, p3, p4, p5, p6, p7;
    // 寫指標,多個生產者,所以宣告為volatile
    private volatile long writeIndex;
    long p11, p12, p13, p14, p15, p16, p17;
    // 讀指標,只有一個消費者,所以不用宣告為volatile
    private long readIndex;
    long p21, p22, p23, p24, p25, p26, p27;
    // 元素個數,生產者和消費者都可能修改,所以宣告為volatile
    private volatile long size;
    long p31, p32, p33, p34, p35, p36, p37;

    // Unsafe變數
    private static final Unsafe UNSAFE;
    // 陣列基礎偏移量
    private static final long ARRAY_BASE_OFFSET;
    // 陣列元素偏移量
    private static final long ARRAY_ELEMENT_SHIFT;
    // writeIndex的偏移量
    private static final long WRITE_INDEX_OFFSET;
    // readIndex的偏移量
    private static final long READ_INDEX_OFFSET;
    // size的偏移量
    private static final long SIZE_OFFSET;

    static {
        Field f = null;
        try {
            // 獲取Unsafe的例項
            f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            UNSAFE = (Unsafe) f.get(null);

            // 計算陣列基礎偏移量
            ARRAY_BASE_OFFSET = UNSAFE.arrayBaseOffset(Object[].class);
            // 計算陣列中元素偏移量
            // 簡單點理解,64位系統中有壓縮指標佔用4個位元組,沒有壓縮指標佔用8個位元組
            int scale = UNSAFE.arrayIndexScale(Object[].class);
            if (4 == scale) {
                ARRAY_ELEMENT_SHIFT = 2;
            } else if (8 == scale) {
                ARRAY_ELEMENT_SHIFT = 3;
            } else {
                throw new IllegalStateException("未知指標的大小");
            }

            // 計算writeIndex的偏移量
            WRITE_INDEX_OFFSET = UNSAFE
                    .objectFieldOffset(MpscArrayQueue.class.getDeclaredField("writeIndex"));
            // 計算readIndex的偏移量
            READ_INDEX_OFFSET = UNSAFE
                    .objectFieldOffset(MpscArrayQueue.class.getDeclaredField("readIndex"));
            // 計算size的偏移量
            SIZE_OFFSET = UNSAFE
                    .objectFieldOffset(MpscArrayQueue.class.getDeclaredField("size"));
        } catch (Exception e) {
            throw new RuntimeException();
        }
    }

    // 構造方法
    public MpscArrayQueue(int capacity) {
        // 取整到2的N次方(未考慮越界)
        capacity = 1 << (32 - Integer.numberOfLeadingZeros(capacity - 1));
        // 例項化陣列
        this.array = (T[]) new Object[capacity];
    }

    // 生產元素
    public boolean put(T t) {
        if (t == null) {
            return false;
        }
        long size;
        long writeIndex;
        do {
            // 每次迴圈都重新獲取size的大小
            size = this.size;
            // 佇列滿了直接返回
            if (size >= this.array.length) {
                return false;
            }

            // 每次迴圈都重新獲取writeIndex的值
            writeIndex = this.writeIndex;

            // while迴圈中原子更新writeIndex的值
            // 如果失敗了重新走上面的過程
        } while (!UNSAFE.compareAndSwapLong(this, WRITE_INDEX_OFFSET, writeIndex, writeIndex + 1));

        // 到這裡,說明上述原子更新成功了
        // 那麼,就把元素的值放到writeIndex的位置
        // 且更新size
        long eleOffset = calcElementOffset(writeIndex, this.array.length-1);
        // 延遲更新到主記憶體,讀取的時候才更新
        UNSAFE.putOrderedObject(this.array, eleOffset, t);

        // 往死裡更新直到成功
        do {
            size = this.size;
        } while (!UNSAFE.compareAndSwapLong(this, SIZE_OFFSET, size, size + 1));

        return true;
    }

    // 消費元素
    public T take() {
        long size = this.size;
        // 如果size為0,表示佇列為空,直接返回
        if (size <= 0) {
            return null;
        }
        // size大於0,肯定有值
        // 只有一個消費者,不用考慮執行緒安全的問題
        long readIndex = this.readIndex;
        // 計算讀指標處元素的偏移量
        long offset = calcElementOffset(readIndex, this.array.length-1);
            // 獲取讀指標處的元素,使用volatile語法,強制更新生產者的資料到主記憶體
        T e = (T) UNSAFE.getObjectVolatile(this.array, offset);

        // 增加讀指標
        UNSAFE.putOrderedLong(this, READ_INDEX_OFFSET, readIndex+1);
        // 減小size
        do {
            size = this.size;
        } while (!UNSAFE.compareAndSwapLong(this, SIZE_OFFSET, size, size-1));

        return e;
    }

    private long calcElementOffset(long index, long mask) {
        // index & mask 相當於取餘數,表示index到達陣列尾端了從頭開始
        return ARRAY_BASE_OFFSET + ((index & mask) << ARRAY_ELEMENT_SHIFT);
    }

}

是不是看不懂?那就對了,多看幾遍吧,面試又能吹一波了。

這裡使用的是每兩個變數之間加7個long型別的變數來消除偽共享,有的開源框架你可能會看到通過繼承的方式實現的,還有的是加15個long型別,另外,JDK8中也提供了一個註解@Contended來消除偽共享。

本例其實還有優化的空間,比如,size的使用,能不能不使用size?不使用size又該如何實現?

後記

本節,我們一起學習了在Java中如何構建高效能的佇列,並學習了一些底層的知識,毫不誇張地講,學會了這些底層知識,面試的時候光佇列就能跟面試官吹一個小時。

另外,最近收到一些同學的反饋,說雜湊、雜湊表、雜湊函式他們之間有關係嗎?有怎樣的關係?為什麼Object中要放一個hash()方法?跟equals()方法怎麼又扯上關係了呢?

下一節,我們就來看看關於雜湊的一切,想及時獲取最新推文嗎?還不快點來關注我!

關注公號主“彤哥讀原始碼”,解鎖更多原始碼、基礎、架構知識。

相關文章