JDK中的BitMap實現之BitSet原始碼分析

throwable發表於2022-01-03

前提

本文主要內容是分析JDK中的BitMap實現之java.util.BitSet的原始碼實現,基於JDK11編寫,其他版本的JDK不一定合適。

文中的圖位元低位實際應該是在右邊,但是為了提高閱讀體驗,筆者把低位改在左邊了。

什麼是BitMap

BitMap,直譯為點陣圖,是一種資料結構,代表了有限域中的稠集(Dense Set),每一個元素至少出現一次,沒有其他的資料和元素相關聯。在索引,資料壓縮等方面有廣泛應用(來源於維基百科詞條)。計算機中1 byte = 8 bit,一個位元(bit,稱為位元或者位)可以表示1或者0兩種值,通過一個位元去標記某個元素的值,而KEY或者INDEX就是該元素,構成一張對映關係圖。因為採用了Bit作為底層儲存資料的單位,所以可以極大地節省儲存空間

Java中,一個int型別的整數佔4位元組,16位元,int的最大值也就是20多億(具體是2147483647)。假設現在有一個需求,在20億整數中判斷某個整數m是否存在,要求使用記憶體必須小於或者等於4GB。如果每個整數都使用int儲存,那麼存放20億個整數,需要20億 * 4byte /1024/1024/1024約等於7.45GB,顯然無法滿足需求。如果使用BitMap,只需要20億 bit記憶體,也就是20億/8/1024/1024/1024約等於0.233GB。在資料量極大的情況下,資料集具備有限狀態,可以考慮使用BitMap儲存和進行後續計算等處理。現在假設用byte陣列去做BitMap的底層儲存結構,初始化一個容量為16BitMap例項,示例如下:

可見當前的byte陣列有兩個元素bitmap[0](虛擬下標為[0,7])和bitmap[1](虛擬下標為[8,15])。這裡假定使用上面構造的這個BitMap例項去儲存客戶ID和客戶性別關係(位元為1代表男性,位元為0代表女性),把ID等於3的男性客戶和ID等於10的女性客戶新增到BitMap中:

由於1 byte = 8 bit,通過客戶ID除以8就可以定位到需要存放的byte陣列索引,再通過客戶ID基於8取模,就可以得到需要存放的byte陣列中具體的bit的索引:

# ID等於3的男性客戶
邏輯索引 = 3
byte陣列索引 = 3 / 8 = 0
bit索引 = 3 % 8 = 3
=> 也就是需要存放在byte[0]的下標為3的位元上,該位元設定為1

# ID等於10的女性客戶
邏輯索引 = 10
byte陣列索引 = 10 / 8 = 1
bit索引 = 10 % 8 = 2
=> 也就是需要存放在byte[1]的下標為2的位元上,該位元設定為0

然後分別判斷客戶ID3或者10的客戶性別:

如果此時再新增一個客戶ID17的男性使用者,由於舊的BitMap只能存放16個位元,所以需要擴容,判斷byte陣列中只需新增一個byte元素(byte[2])即可:

原則上,底層的byte陣列可以不停地擴容,當byte陣列長度達到Integer.MAX_VALUEBitMap的容量達到最大值。

BitSet簡單使用

java.util.BitSet雖然名字上稱為Set,但實際上它就是JDK中內建的BitMap實現,1這個類算是一個十分古老的類,從註釋上看是JDK1.0引入的,不過大部分方法是JDK1.4之後新新增或者更新的。以前一小節的例子基於BitSet做一個Demo

public class BitSetApp {

    public static void main(String[] args) {
        BitSet bitmap = new BitSet(16);
        bitmap.set(3, Boolean.TRUE);
        bitmap.set(11, Boolean.FALSE);
        System.out.println("Index 3 of bitmap => " + bitmap.get(3));
        System.out.println("Index 11 of bitmap => " + bitmap.get(11));
        bitmap.set(17, Boolean.TRUE);
        // 這裡不會觸發擴容,因為BitSet中底層儲存陣列是long[]
        System.out.println("Index 17 of bitmap => " + bitmap.get(17));
    }
}

// 輸出結果
Index 3 of bitmap => true
Index 11 of bitmap => false
Index 17 of bitmap => true

API使用比較簡單,為了滿足其他場景,BitSet還提供了幾個實用的靜態工廠方法用於構造例項,範圍設定和清除位元值和一些集合運算等,這裡不舉例,後面分析原始碼的時候會詳細展開。

BitSet原始碼分析

前文提到,BitMap如果使用byte陣列儲存,當新新增元素的邏輯下標超過了初始化的byte陣列的最大邏輯下標就必須進行擴容。為了儘可能減少擴容的次數,除了需要按實際情況定義初始化的底層儲存結構,還應該選用能夠"承載"更多位元的資料型別陣列,因此在BitSet中底層的儲存結構選用了long陣列,一個long整數佔64位元,位長是一個byte整數的8倍,在需要處理的資料範圍比較大的場景下可以有效減少擴容的次數。後文為了簡化分析過程,在模擬底層long陣列變化時候會使用盡可能少的元素去模擬。BitSet頂部有一些關於其設計上的註釋,這裡簡單羅列概括成幾點:

  • BitSet是可增長位元向量的一個實現,設計上每個位元都是一個布林值,位元的邏輯索引是非負整數
  • BitSet的所有位元的初始化值為false(整數0
  • BitSetsize屬性與其實現有關,length屬性(位元表的邏輯長度)與實現無關
  • BitSet在設計上是非執行緒安全,多執行緒環境下需要額外的同步處理

按照以往分析原始碼的習慣,先看BitSet的所有核心成員屬性:

public class BitSet implements Cloneable, java.io.Serializable {
    
    // words是long陣列,一個long整數為64bit,2^6 = 64,這裡選取6作為words的定址引數,可以基於邏輯下標快速定位到具體的words中的元素索引
    private static final int ADDRESS_BITS_PER_WORD = 6;

    // words中每個元素的位元數,十進位制值是64
    private static final int BITS_PER_WORD = 1 << ADDRESS_BITS_PER_WORD;

    // bit下標掩碼,十進位制值是63
    private static final int BIT_INDEX_MASK = BITS_PER_WORD - 1;

    // 掩碼,十進位制值-1,也就是64個位元全是1,用於部分word掩碼的左移或者右移
    private static final long WORD_MASK = 0xffffffffffffffffL;

    /**
     * 序列化相關,略過
     */
    private static final ObjectStreamField[] serialPersistentFields = {
        new ObjectStreamField("bits", long[].class),
    };

    /**
     * 底層的位元儲存結構,long陣列,同時也是序列化欄位"bits"的對應值
     */
    private long[] words;

    /**
     * 已經使用的words陣列中的元素個數,註釋翻譯:在當前BitSet的邏輯長度中的word(words的元素)個數,瞬時值
     */
    private transient int wordsInUse = 0;

    /**
     * 標記words陣列的長度是否使用者
     */
    private transient boolean sizeIsSticky = false;

    // JDK 1.0.2使用的序列化版本號
    private static final long serialVersionUID = 7997698588986878753L;

    // 暫時省略其他方法
}

接著看BitSet的幾個輔助方法:

// 基於bit的邏輯下標定位words中word元素的索引,直接右移6位
// 舉例:bitIndex = 3,那麼bitIndex >> ADDRESS_BITS_PER_WORD => 0,說明定位到words[0]
// 舉例:bitIndex = 35,那麼bitIndex >> ADDRESS_BITS_PER_WORD => 1,說明定位到words[1]
private static int wordIndex(int bitIndex) {
    return bitIndex >> ADDRESS_BITS_PER_WORD;
}

// 每個公共方法都必須保留這些不變數,內部變數的恆等式校驗,字面意思就是每個公共方法必須呼叫此恆等式校驗
// 第一條恆等式:當前BitSet為空或者最後一個words元素不能為0(其實就是當前BitSet不為空)
// 第二條恆等式:wordsInUse邊界檢查,範圍是[0, words.length]
// 第三條恆等式:wordsInUse或者等於words.length,意味著用到了所有words的元素;或者words[wordsInUse] == 0,意味著words中索引為[wordsInUse, words.length - 1]的元素都沒有被使用
private void checkInvariants() {
    assert(wordsInUse == 0 || words[wordsInUse - 1] != 0);
    assert(wordsInUse >= 0 && wordsInUse <= words.length);
    assert(wordsInUse == words.length || words[wordsInUse] == 0);
}

// 重新計算wordsInUse的值,也就是重新整理已使用的words元素計算值
// 基於當前的wordsInUse - 1向前遍歷到i = 0,找到最近一個不為0的words[i],然後重新賦值為i + 1,這裡i是words陣列的索引
// wordsInUse其實是words陣列最後一個不為0的元素的下標加1,或者說用到的words的元素個數,稱為邏輯容量(logical size)
private void recalculateWordsInUse() {
    // Traverse the bitset until a used word is found
    int i;
    for (i = wordsInUse-1; i >= 0; i--)
        if (words[i] != 0)
            break;

    wordsInUse = i+1; // The new logical size
}

然後看BitSet的建構函式和靜態工廠方法:

// 預設的公共構造方法,位元表的邏輯長度為64,words陣列長度為2,標記sizeIsSticky為false,也就是位元表的長度不是使用者自定義的
public BitSet() {
    initWords(BITS_PER_WORD);
    sizeIsSticky = false;
}

// 自定義位元表邏輯長度的構造方法,該長度必須為非負整數,標記sizeIsSticky為true,也就是位元表的長度是由使用者自定義的
public BitSet(int nbits) {
    if (nbits < 0)
        throw new NegativeArraySizeException("nbits < 0: " + nbits);
    initWords(nbits);
    sizeIsSticky = true;
}

// 初始化words陣列,陣列的長度為length = (nbits - 1) / 64 + 1
// 例如nbits = 16,相當於long[] words = new long[(16 - 1) / 64 + 1] => new long[1];
// 例如nbits = 65,相當於long[] words = new long[(65 - 1) / 64 + 1] => new long[2];
// 以此類推
private void initWords(int nbits) {
    words = new long[wordIndex(nbits-1) + 1];
}

// 直接自定義底層的words陣列構造方法,標記所有words的元素都被使用
private BitSet(long[] words) {
    this.words = words;
    this.wordsInUse = words.length;
    checkInvariants();
}

// 直接自定義底層的words陣列構造方法,這個構造方法和上一個方法不一樣,會從入參long陣列從後面開始遍歷,直到遍歷到第一個元素或者不為0的元素,這樣可以儘量截斷無用的高位的0元素
// 簡單來說就是相當於:BitSet.valueOf(new long[]{1L, 0L}) = 移除後面的0元素 => BitSet.valueOf(new long[]{1L})
public static BitSet valueOf(long[] longs) {
    int n;
    for (n = longs.length; n > 0 && longs[n - 1] == 0; n--)
        ;
    return new BitSet(Arrays.copyOf(longs, n));
}

// 直接自定義底層的words陣列構造方法,要求入參為LongBuffer型別,需要把LongBuffer => long[] words,方法和BitSet valueOf(long[] longs)處理邏輯一致
public static BitSet valueOf(LongBuffer lb) {
    lb = lb.slice();
    int n;
    for (n = lb.remaining(); n > 0 && lb.get(n - 1) == 0; n--)
        ;
    long[] words = new long[n];
    lb.get(words);
    return new BitSet(words);
}

// 下面兩個構造方法都是基於byte陣列從後面開始遍歷,直到遍歷到第一個元素或者不為0的元素,截斷出一個新的陣列,然後轉化為long陣列構造BitSet例項
public static BitSet valueOf(byte[] bytes) {
    return BitSet.valueOf(ByteBuffer.wrap(bytes));
}

public static BitSet valueOf(ByteBuffer bb) {
    // 小端位元組排序
    bb = bb.slice().order(ByteOrder.LITTLE_ENDIAN);
    int n;
    // 從後向前遍歷獲到第一個元素或者第一個不為0的元素
    for (n = bb.remaining(); n > 0 && bb.get(n - 1) == 0; n--)
        ;
    // 這裡需要考慮位元組容器中的位元組元素個數不是8的倍數的情況
    long[] words = new long[(n + 7) / 8];
    // 截斷後面的0元素
    bb.limit(n);
    int i = 0;
    // 剩餘元素個數大於等於8時候,按照64位去讀取
    while (bb.remaining() >= 8)
        words[i++] = bb.getLong();
    // 剩餘元素個數小於8時候,按照byte讀取,並且通過掩碼計算和左移填充到long陣列元素中
    for (int remaining = bb.remaining(), j = 0; j < remaining; j++)
        words[i] |= (bb.get() & 0xffL) << (8 * j);
    return new BitSet(words);
}

這裡建構函式的原始碼不是十分複雜,比較繁瑣的是靜態工廠方法BitSet valueOf(ByteBuffer bb),這裡舉例推演一下:

ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
byteBuffer.putLong(1L);
byteBuffer.put((byte)3);
byteBuffer.put((byte)1);
byteBuffer.flip();
BitSet bitSet = BitSet.valueOf(byteBuffer);
System.out.println(bitSet.size());
System.out.println(bitSet.length());

// 輸出結果
128
73

過程如下:

接著看常規的setgetclear方法:

// 設定指定的邏輯索引的位元為true
public void set(int bitIndex) {
    // 位元邏輯索引必須大於等於0
    if (bitIndex < 0)
        throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
    // 計算words陣列元素的索引
    int wordIndex = wordIndex(bitIndex);
    // 判斷是否需要擴容,如果需要則進行words陣列擴容
    expandTo(wordIndex);
    // 相當於words[wordIndex] = words[wordIndex] | (1L << bitIndex)
    // 注意long的左移如果超過63位會溢位,也就是1L << 64 => 1L,1L << 65 => 1L << 1,1L << 66 => 1L << 2... 以此類推
    // 這裡相當於把左移結果直接和對應的words元素進行或運算,前者因為是基於1進行左移,二進位制數一定是隻有一個位元為1,其他位元都是0的64位元二級制序列,或運算會讓對應的words元素與前者對應的位元為1的位元值設定為1,並且重新賦值對應的words元素
    // 類似於這樣:0000 0000 | 0000 1000 => 0000 1000
    words[wordIndex] |= (1L << bitIndex); // Restores invariants
    // 不變數恆等式斷言校驗
    checkInvariants();
}

// 基於計算的words陣列元素下標進行擴容
private void expandTo(int wordIndex) {
    // 計算當前的words元素下標需要的words陣列長度
    int wordsRequired = wordIndex + 1;
    // 如果當前的words元素下標需要的words陣列長度大於當前已經使用的words陣列中的元素個數,則進行擴容(未必一定擴容陣列,擴容方法裡面還有一層判斷)
    if (wordsInUse < wordsRequired) {
        // 基於需要的words陣列長度進行擴容
        ensureCapacity(wordsRequired);
        // 重置當前已經使用的words陣列中的元素個數
        wordsInUse = wordsRequired;
    }
}

// 基於計算的words陣列元素下標進行擴容,滿足前提下進行陣列拷貝
private void ensureCapacity(int wordsRequired) {
    // 當前words陣列長度比需要的words陣列長度小,則進行擴容
    if (words.length < wordsRequired) {
        // 分配的新陣列的長度是舊words陣列元素和傳入的需要的words陣列長度之間的最大值
        int request = Math.max(2 * words.length, wordsRequired);
        // 陣列擴容
        words = Arrays.copyOf(words, request);
        // 因為已經進行了擴容,所以要標記位元表的長度不是使用者自定義的
        sizeIsSticky = false;
    }
}

// 獲取指定的邏輯索引的位元的狀態
public boolean get(int bitIndex) {
    // 位元邏輯索引必須大於等於0
    if (bitIndex < 0)
        throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
    // 不變數恆等式斷言校驗
    checkInvariants();
    // 計算words陣列元素的索引
    int wordIndex = wordIndex(bitIndex);
    // words陣列元素的索引必須小於正在使用的words元素個數,並且把1L左移bitIndex結果直接和對應的words元素進行與運算的結果不是全部位元為0則返回true,否則返回false
    // 類似於這樣(返回true的場景):0000 1010 & 0000 1000 => 0000 1000 => 說明定位到的words中的元素曾經通過set方法設定過對應1L << bitIndex的位元為1
    // 類似於這樣(返回false的場景):0000 0110 & 0000 1000 => 0000 0000 => 說明定位到的words中的元素未曾通過set方法設定過對應1L << bitIndex的位元為1,對應位元使用的是預設值0
    return (wordIndex < wordsInUse) && ((words[wordIndex] & (1L << bitIndex)) != 0);
}

// 設定指定的邏輯索引的位元為false
public void clear(int bitIndex) {
    // 位元邏輯索引必須大於等於0
    if (bitIndex < 0)
        throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
    // 計算words陣列元素的索引
    int wordIndex = wordIndex(bitIndex);
    // 如果words陣列元素的索引大於等於正在使用的words元素個數,說明該邏輯下標的位元處於初始化狀態還未被使用,不用處理
    if (wordIndex >= wordsInUse)
        return;
    // 相當於words[wordIndex] = words[wordIndex] & (~(1L << bitIndex))
    // 把左移結果各位元取反然後和對應的words元素進行與運算,再重新賦值到對應的words元素
    // 類似於:0000 1100 & (~(0000 1000)) => 0000 1100 & 1111 0111 => 0000 0100
    words[wordIndex] &= ~(1L << bitIndex);
    // 重新計算wordsInUse的值,也就是重新整理已使用的words元素計算值
    recalculateWordsInUse();
    // 不變數恆等式斷言校驗
    checkInvariants();
}

這裡模擬一下set方法的過程:

接著看集合運算相關的方法:

// 判斷兩個BitSet是否存在交集,這是一個判斷方法,不會修改當前BitSet的結構
public boolean intersects(BitSet set) {
    // 對比當前BitSet例項和入參BitSet例項使用的words元素數量,取較小值作為遍歷基準
    for (int i = Math.min(wordsInUse, set.wordsInUse) - 1; i >= 0; i--)
        // 遍歷和對比每一個words陣列的元素,只要滿足與運算結果不為0就返回true(這個條件是很寬鬆的,只要底層邏輯位元表剛好兩個BitSet例項在同一邏輯索引的位元都為1即可滿足)
        if ((words[i] & set.words[i]) != 0)
            return true;
    return false;
}

// AND運算,底層是兩個BitSet例項對應索引的words陣列元素進行與運算,直觀來看就是計算兩個BitSet例項的交集,存放在本BitSet例項中
public void and(BitSet set) {
    // 入參為本BitSet例項,不進行處理
    if (this == set)
        return;
    // 如果當前BitSet例項已經使用的words陣列元素數量比傳入的多,那麼當前BitSet例項把多出來那部分words陣列元素置為0
    while (wordsInUse > set.wordsInUse)
        words[--wordsInUse] = 0;
    // 遍歷當前的BitSet例項的words陣列已使用的元素,與傳入的BitSet例項的相同索引元素進行與運算和重新賦值
    for (int i = 0; i < wordsInUse; i++)
        words[i] &= set.words[i];
    // 重新計算wordsInUse的值,也就是重新整理已使用的words元素計算值
    recalculateWordsInUse();
    // 不變數恆等式斷言校驗
    checkInvariants();
}

// OR運算,底層是兩個BitSet例項對應索引的words陣列元素進行或運算,直觀來看就是計算兩個BitSet例項的並集,存放在本BitSet例項中
public void or(BitSet set) {
    // 入參為本BitSet例項,不進行處理
    if (this == set)
        return;
    // 計算兩個BitSet中words陣列已使用元素的公共部分,其實也就是較小的wordsInUse
    int wordsInCommon = Math.min(wordsInUse, set.wordsInUse);
    // 當前的BitSet例項的words陣列已使用的元素數量比傳入的BitSet例項小,以傳入的例項為準進行擴容,並且拷貝其wordsInUse值
    if (wordsInUse < set.wordsInUse) {
        ensureCapacity(set.wordsInUse);
        wordsInUse = set.wordsInUse;
    }
    // 兩個BitSet例項words陣列已使用元素的公共部分分別按索引進行或運算,賦值在當前BitSet例項對應索引的words元素
    for (int i = 0; i < wordsInCommon; i++)
        words[i] |= set.words[i];
    // 如果傳入的BitSet例項還有超出words陣列已使用元素的公共部分,這部分words陣列元素也拷貝到當前的BitSet例項中,因為前面有擴容判斷,走到這裡當前BitSet例項的wordsInUse大於等於傳入的BitSet例項的wordsInUse
    if (wordsInCommon < set.wordsInUse)
        System.arraycopy(set.words, wordsInCommon,
                            words, wordsInCommon,
                            wordsInUse - wordsInCommon);
    // 不變數恆等式斷言校驗
    checkInvariants();
}

// XOR運算,底層是兩個BitSet例項對應索引的words陣列元素進行異或運算,實現和OR基本相似,完成處理後需要重新計算當前BitSet例項的wordsInUse值
public void xor(BitSet set) {
    int wordsInCommon = Math.min(wordsInUse, set.wordsInUse);
    if (wordsInUse < set.wordsInUse) {
        ensureCapacity(set.wordsInUse);
        wordsInUse = set.wordsInUse;
    }
    // Perform logical XOR on words in common
    for (int i = 0; i < wordsInCommon; i++)
        words[i] ^= set.words[i];

    // Copy any remaining words
    if (wordsInCommon < set.wordsInUse)
        System.arraycopy(set.words, wordsInCommon,
                            words, wordsInCommon,
                            set.wordsInUse - wordsInCommon);
    recalculateWordsInUse();
    checkInvariants();
}


// AND NOT運算,底層是兩個BitSet例項對應索引的words陣列元素進行與運算之前傳入BitSet例項對應索引的words陣列元素先做非運算,過程和AND運算類似
public void andNot(BitSet set) {
    // Perform logical (a & !b) on words in common
    for (int i = Math.min(wordsInUse, set.wordsInUse) - 1; i >= 0; i--)
        words[i] &= ~set.words[i];
    recalculateWordsInUse();
    checkInvariants();
}

這裡模擬一下and方法的過程:

接著看搜尋相關方法(nextXXXBitpreviousYYYBit),這裡以nextSetBit(int fromIndex)方法為例子:

// 以位元邏輯索引fromIndex為起點,向後搜尋並且返回第一個狀態為true的位元的邏輯索引,搜尋失敗則返回-1
public int nextSetBit(int fromIndex) {
    // 起始的位元邏輯索引必須大於等於0
    if (fromIndex < 0)
        throw new IndexOutOfBoundsException("fromIndex < 0: " + fromIndex);
    // 不變數恆等式斷言校驗
    checkInvariants();
    // 基於起始的位元邏輯索引計算words陣列元素的索引
    int u = wordIndex(fromIndex);
    // words陣列元素的索引超過已經使用的words陣列元素數量,說明陣列越界,直接返回-1
    if (u >= wordsInUse)
        return -1;
    // 這裡和之前的set方法的左移類似,不過使用了-1L進行左移,例如-1L << 2 => 1111 1111 << 2 => 1111 1100(這裡假設限制長度8,溢位的高位捨棄)
    // 舉例:0000 1010 & (1111 1111 << 2) => 0000 1010 & 1111 1100 => 0000 1000(索引值為4,當前這裡不一定得到存在位元為1的結果)
    long word = words[u] & (WORD_MASK << fromIndex);
    // 基於得到的word進行遍歷
    while (true) {
        // 說明word中存在位元為1,計算和返回該位元的邏輯索引
        if (word != 0)
            // 基於起始的位元邏輯索引計算words陣列元素的索引 * 64 + word中低位連續為0的位元數量
            return (u * BITS_PER_WORD) + Long.numberOfTrailingZeros(word);
        // 說明word中全部位元為0,則u需要向後遞增,等於wordsInUse說明越界返回-1,這個if存在賦值和判斷兩個邏輯
        if (++u == wordsInUse)
            return -1;
        word = words[u];
    }
}

nextSetBit(int fromIndex)方法先查詢fromIndex所在的words陣列元素,不滿足後再向後進行檢索,該方法註釋中還給出了一個經典的使用例子,這裡摘抄一下:

BitSet bitmap = new BitSet();
// add element to bitmap
// ... bitmap.set(1993);
for (int i = bitmap.nextSetBit(0); i >= 0; i = bitmap.nextSetBit(i + 1)) {
    // operate on index i here
    if (i == Integer.MAX_VALUE) {
        break; // or (i+1) would overflow
    }
}

最後看規格屬性相關的一些Getter方法:

// 獲取BitSet中的位元總數量
public int size() {
    // words陣列長度 * 64
    return words.length * BITS_PER_WORD;
}

// 獲取BitSet中的位元值為1的總數量
public int cardinality() {
    int sum = 0;
    // 獲取每一個已使用的words陣列元素的位元為1的數量然後累加
    for (int i = 0; i < wordsInUse; i++)
        sum += Long.bitCount(words[i]);
    return sum;
}

// 獲取BitSet的邏輯大小(不計算只是初始化但是未使用的高位位元),簡單來說就是words[wordsInUse - 1]中去掉高位位元連續0的第一個位元值為1的邏輯索引,例如0001 1010,高位連續3個0,邏輯索引為4
public int length() {
    if (wordsInUse == 0)
        return 0;
    return BITS_PER_WORD * (wordsInUse - 1) +
        (BITS_PER_WORD - Long.numberOfLeadingZeros(words[wordsInUse - 1]));
}

其他按範圍設定或者清除值如set(int fromIndex, int toIndex)clear(int fromIndex, int toIndex)等方法限於篇幅不進行詳細分析,套路大致是相似的。

BitSet沒有解決的問題

ADDRESS_BITS_PER_WORD的欄位註釋中也提到過:The choice of word size is determined purely by performance concerns'word'大小的選擇純粹是由效能考慮決定的)。這裡的定址基礎值6的選擇是因為底層選用了long陣列,儘可能減少底層陣列的擴容次數。但是這裡存在一個比較矛盾的問題,似乎在JDK中沒有辦法找到位寬比long大而且佔用記憶體空間跟long等效的資料型別,像byteString(底層是char陣列)等等都會在擴容次數方面遠超long型別。因為底層是陣列儲存結構,並且沒有限定陣列中元素的下邊界和上邊界,在一些特定的場景下會浪費過多的無用記憶體空間。以前文提到過的例子做改造,如果要把10億到20億之間的整數全部加裝到BitSet例項中(這些值在BitSet的對應的邏輯位元索引的值都標記為1),那麼在BitSet例項的底層位元表中,邏輯索引[0,10億)的值都會是初始化值0,也就是約一半的words[N]的值都是0,這部分記憶體空間是完全被浪費掉的。在實際的業務場景中,很多時候業務的主鍵並不是使用資料庫的自增主鍵,而是使用通過SnowFlake這類演算法生成的帶自增趨勢的數值主鍵,如果演算法定義的基準時間戳比較大,那麼生成出來的值會遠超int型別的上限(使用long型別承載)。也就是BitSet沒有解決的問題如下:

  • 問題一:位元表的邏輯索引值上限是Integer.MAX_VALUE,目前沒有辦法擴充套件成Long.MAX_VALUE,原因是JDK中陣列在底層設計的length屬性是int型別的,可以從java.lang.reflect.Array類中得知此限制,筆者認為暫時無法從底層解決這個問題
  • 問題二:BitSet沒有考慮已知位元表的邏輯索引範圍的場景優化,也就是必須儲存[0,下邊界)這部分0值,在一些特定場景下會浪費過多的無用記憶體空間

對於問題一,可以考慮做一個簡單的對映。假設現在需要儲存[Integer.MAX_VALUE + 1,Integer.MAX_VALUE + 3]BitSet例項中,可以實際儲存[1,3],處理完畢後,通過long realIndex = (long) bitIndex + Integer.MAX_VALUE恢復實際的索引值,這樣就可以邏輯上擴大BitSet的儲存範圍,猜測可以滿足99%以上的場景:

public class LargeBitSetApp {

    public static void main(String[] args) {
        long indexOne = Integer.MAX_VALUE + 1L;
        long indexTwo = Integer.MAX_VALUE + 2L;
        long indexThree = Integer.MAX_VALUE + 3L;
        BitSet bitmap = new BitSet(8);
        // set(int fromIndex, int toIndex) => [fromIndex,toIndex)
        bitmap.set((int) (indexOne - Integer.MIN_VALUE), (int) (indexThree - Integer.MIN_VALUE) + 1);
        System.out.printf("Index = %d,value = %s\n", indexOne, bitmap.get((int) (indexOne - Integer.MIN_VALUE)));
        System.out.printf("Index = %d,value = %s\n", indexTwo, bitmap.get((int) (indexTwo - Integer.MIN_VALUE)));
        System.out.printf("Index = %d,value = %s\n", indexThree, bitmap.get((int) (indexThree - Integer.MIN_VALUE)));
    }
}

// 輸出結果
Index = 2147483648,value = true
Index = 2147483649,value = true
Index = 2147483650,value = true

對於問題二,已經有現成的實現,就是類庫RoaringBitmap,倉庫地址是:https://github.com/RoaringBitmap/RoaringBitmap。該類庫被Apache的多個大資料元件使用,經得起生產環境的考驗。引入依賴如下:

<dependency>
    <groupId>org.roaringbitmap</groupId>
    <artifactId>RoaringBitmap</artifactId>
    <version>0.9.23</version>
</dependency>

簡單使用:

public class RoaringBitmapApp {

    public static void main(String[] args) {
        RoaringBitmap bitmap = RoaringBitmap.bitmapOf(1, 2, 3, Integer.MAX_VALUE, Integer.MAX_VALUE - 1);
        System.out.println(bitmap.contains(Integer.MAX_VALUE));
        System.out.println(bitmap.contains(1));
    }
}

// 輸出結果
true
true

RoaringBitmap區分了不同場景去建立底層的儲存容器:

  • 稀疏場景:使用ArrayContainer容器,元素數量小於4096
  • 密集場景:使用BitmapContainer容器,類似java.util.BitSet的實現,元素數量大於4096
  • 聚集場景(理解為前兩種場景的混合):使用RunContainer容器

小結

學習和分析BitSet的原始碼是為了更好地在實際場景中處理有限狀態的大批量資料集合之間的運算,剛好在筆者的業務中有匹配的場景,後面有機會的話在另一篇實戰文章中再詳細展開。

參考資料:

(本文完 c-4-d e-a-20220103)

相關文章