Lucene源码系列(八):基于内存的位图实现方案

1,009 阅读9分钟

背景

在正式介绍什么是位图之前,我们先看个问题:

在给定的40亿个无符号整型中,如何判断是否存在目标无符号整型x?

比较直接的想法是,我们借用一些语言自带的集合工具Set,或者是用数组存储再遍历查找或者是对数据排序之后进行二分查找,这些解决方案理论上确实也能解决这个问题。但是我们来算一下,40亿 * 4字节 差不多是15Gb,所以上面问题的挑战在于如何使用适量的内存解决存在性判定。

今天我们要介绍的位图就是专门来解决这个问题的。位图从名称上 就可以看出来,是按位处理数据的。对于一个整型的32位,其中每一位用来标记一个整型是否存在,如下图所示,原来是32位表示一个数据,现在则是1位表示一个数据,相当于整个空间的使用缩减了32倍,只用差不多500Mb就可以存储40亿个整型了,这就是位图的魅力。

BitSet.png

本文后面的内容主要介绍位图在Lucene中的多种实现方案。因为位图一般使用数组实现的,数组的大小是有限制的,所以Lucene中是使用long数组来表示位图,可以容纳更多的位图信息。本文涉及的lucene源码版本是9.1.0。

Lucene位图实现

FixedBitSet

FixedBitSet是位图最朴素的实现方案,它就是使用long中的每一位来表示位图的每一个位置,整个位图是由多个long组成的,内部实现为一个long数组。而FixedBitSet中的核心问题就是怎么定位一个位图的位置:在哪个long中以及在long中的什么位置?

核心成员变量

// 存储位图的long数组
private final long[] bits; 
// 真正使用的位图大小(构造函数传入设置的)
private final int numBits; 
// 真正使用的bits的长度,numwords <= bits.length
private final int numWords;

构造函数

FixedBitSet构造函数的主要逻辑是一次性创建好整个位图。也就是整个位图需要多少空间,在初始化的时候都已经申请好了。具体看下面的代码更清楚,注意第二个构造方法是使用已有的数组作为位图,只是做了一个空间大小的判断。

public FixedBitSet(int numBits) {
  this.numBits = numBits;
  bits = new long[bits2words(numBits)];
  numWords = bits.length;
}

public FixedBitSet(long[] storedBits, int numBits) {
  this.numWords = bits2words(numBits);
  if (numWords > storedBits.length) {
    throw new IllegalArgumentException(
        "The given long array is too small  to hold " + numBits + " bits");
  }
  this.numBits = numBits;
  this.bits = storedBits;
}

// numBits位需要几个long
public static int bits2words(int numBits) {
  return ((numBits - 1) >> 6) + 1;
}

关键方法

FixedBitSet中最核心的逻辑,如何设置指定的位置和如何查找指定的位置?

// 设置位图中指定的位置
public void set(int index) {
  assert index >= 0 && index < numBits : "index=" + index + ", numBits=" + numBits;
  // 在第几个long  
  int wordNum = index >> 6; 
  // 计算掩码,也就是要设置的long中的哪一位,注意 << 是循环左移  
  long bitmask = 1L << index;
  // 对应位置设1  
  bits[wordNum] |= bitmask;
}

// 查询位图中指定的位置
public boolean get(int index) {
  assert index >= 0 && index < numBits : "index=" + index + ", numBits=" + numBits;
  // 在第几个long      
  int i = index >> 6; 
  // 计算掩码,也就是要设置的long中的哪一位,注意 << 是循环左移      
  long bitmask = 1L << index;
  // 判断对应的位置是否是1  
  return (bits[i] & bitmask) != 0;
}

FixedBitSet例子

因为设置和查询位图中的位置的主要逻辑是一样的,所以我们直接看一些设置位图位置的例子:

public class FixedBitSetDemo {
    public static void main(String[] args) {
        FixedBitSet fixedBitSet = new FixedBitSet(300);
        fixedBitSet.set(2);
        fixedBitSet.set(250);
        fixedBitSet.set(260);
        fixedBitSet.set(67);
    }
}
构造函数
FixedBitSet fixedBitSet = new FixedBitSet(300);

构造函数的中参数300是需要的位图长度大小,因为FixedBitSet底层是用long数组存储位图,所以会根据传入的长度计算至少需要多少个long,300的话至少需要的5个long(5*64=320 > 300),所以初始化完成之后的位图结构如下:

FixedBitSet_1.png

设置2
fixedBitSet.set(2);
  1. 定位到2在哪个long:2 >> 6 = 0,所以2对应的位图位置应该在bits[0]中
  2. 计算在long中位置的掩码: 1 << 2 = 0b00000100
  3. 设置在long中对应的位置:bits[0] | 0b00000100
  4. 结果如下:

FixedBitSet_2.png

设置250
fixedBitSet.set(250);
  1. 定位到250在哪个long:250 >> 6 = 3,所以250对应的位图位置应该在bits[3]中
  2. 计算在long中位置的掩码: 1 << 250 = 0b00000100_00000000_00000000_00000000_00000000_00000000_00000000_00000000
  3. 设置在long中对应的位置:bits[3] | 0b00000100_00000000_00000000_00000000_00000000_00000000_00000000_00000000
  4. 结果如下:

FixedBitSet_3.png

设置260
fixedBitSet.set(260);
  1. 定位到260在哪个long:260 >> 6 = 4,所以260对应的位图位置应该在bits[4]中
  2. 计算在long中位置的掩码: 1 << 260 = 0b00010000
  3. 设置在long中对应的位置:bits[4] | 0b00010000
  4. 结果如下:

FixedBitSet_4.png

设置67
fixedBitSet.set(67);
  1. 定位到67在哪个long:67 >> 6 = 1,所以67对应的位图位置应该在bits[1]中
  2. 计算在long中位置的掩码: 1 << 67 = 0b00001000
  3. 设置在long中对应的位置:bits[1] | 0b00001000
  4. 结果如下:

FixedBitSet_5.png

FixedBitSet中其他的一些方法

FixedBitSet中还有一些其他的方法,比如求两个FixedBitSet的并集,交集等等,实现都比较简单,大家可自行查看。

FixedBitSet总结

从上面例子中,我们可以看到最后bits[2]是没有用到的,也就是说浪费了一个long的空间。如果在实际使用时,位图非常稀疏,则浪费的空间就比较多,因此,FixedBitSet适用于稠密型的位图场景。

SparseFixedBitSet

为了解决稀疏场景中,FixedBitSet空间利用率低的问题,Lucene中设计了SparseFixedBitSet专门应用于稀疏场景。在SparseFixedBitSet中引入了block的概念,一个block是64个long,也就是4096个bit。block中的long是按需分配,从而避免了无用long的空间浪费。

SparseFixedBitSet_1.png

如上图所示,block的引入使得整个图结构称为一个分层的结构,indices用来管理所有的block,位图中的具体位置需要先定位在哪个block,然后再定位到block中的哪个long,最后定位到long中的具体位置。

核心成员变量

// 下标是block的id,值是block中有值long的位图
final long[] indices;
// bits[i]存储的是第i个block中所有有值的long的数组,
// 比如第0个block中存的是0-63范围中有值的long,如果有值的是3,7,9,则按顺序存储在bits[0]中
final long[][] bits;

构造函数

public SparseFixedBitSet(int length) {
  if (length < 1) {
    throw new IllegalArgumentException("length needs to be >= 1");
  }
  this.length = length;
  final int blockCount = blockCount(length);
  indices = new long[blockCount];
  // 注意bits对应的每个block中初始化都是空的,后面是按需加入有值的long 
  bits = new long[blockCount][];
  ramBytesUsed =
      BASE_RAM_BYTES_USED
          + RamUsageEstimator.sizeOf(indices)
          + RamUsageEstimator.shallowSizeOf(bits);
}

// length个bit需要多少个block
private static int blockCount(int length) {
  int blockCount = length >>> 12;
  if ((blockCount << 12) < length) {
    ++blockCount;
  }
  assert (blockCount << 12) >= length;
  return blockCount;
}

关键方法

位图的设置需要分为三种情况:

  • block还未初始化

​ 这种情况,则需要初始化bits中对应的block的数组,并且可以直接把目标long加入数组中。

  • block中不存在目标long

    • 如果当前的block的数组容量不足,则先扩容
    • 计算目标long在block数组中的下标
    • 执行数组的插入算法,把目标long插入到数组中
  • block中已存在目标long

    • 计算目标long在block数组中的下标
    • 设置目标long对应的位置

对应设置的三种情况,位图的查找也分为三种情况:

  • block不存在

    这种情况说明目标不存在

  • block存在,但是目标long不存在

    这种情况说明目标不存在

  • block存在并且目标long存在

    需要获取目标位置的具体情况来判断

public void set(int i) {
  assert consistent(i);
  // i在第i4096个block中 
  final int i4096 = i >>> 12;
  // index是第i4096个block中有效long的位图
  final long index = indices[i4096];
  // i在block中的第i64个long中
  final int i64 = i >>> 6;
  // i64在block中位置的位图
  final long i64bit = 1L << i64;
  if ((index & i64bit) != 0) { // 如果i所在的long已经存在在block中
    // index & (i64bit - 1) 只保留排在i64bit之前的所有long的位图信息
    // 计算 index & (i64bit - 1) 中1的个数就可以得到i在block中的下标
    bits[i4096][Long.bitCount(index & (i64bit - 1))] |= 1L << i; 
  } else if (index == 0) { // i对应的block还未初始化,也就是i是block中第一次出现的
    insertBlock(i4096, i64bit, i);
  } else { // i对应的block已经初始化,但是i是所在long中第一次出现的
    insertLong(i4096, i64bit, i, index);
  }
}

// 新初始化一个block
private void insertBlock(int i4096, long i64bit, int i) {
  indices[i4096] = i64bit;
  assert bits[i4096] == null;
  //   1L << i  相当于已经设置了位图
  bits[i4096] = new long[] {1L << i}; 
  ++nonZeroLongCount;
  ramBytesUsed += SINGLE_ELEMENT_ARRAY_BYTES_USED;
}

// i4096: i在第几个block
// i64bit: i在block中的第几个long
// index: 当前block中已经存在的long的位图布局  
private void insertLong(int i4096, long i64bit, int i, long index) {
  // 把当前要创建的long记录到block的位图中  
  indices[i4096] |= i64bit;
  // 当前要新增的long在block中的下标
  final int o = Long.bitCount(index & (i64bit - 1));
  final long[] bitArray = bits[i4096];  
  if (bitArray[bitArray.length - 1] == 0) { // 如果block中还有剩余空间
    // 在数组中间插入新元素的操作,指定的位置后面的所有元素往后挪一个位置
    System.arraycopy(bitArray, o, bitArray, o + 1, bitArray.length - o - 1);
    // 设置当前long  
    bitArray[o] = 1L << i;
  } else { // 空间不足,则需要先扩容
    // 1.5倍扩容,所以才有上面的if中的逻辑  
    final int newSize = oversize(bitArray.length + 1);
    final long[] newBitArray = new long[newSize];
    // 拷贝前半段  
    System.arraycopy(bitArray, 0, newBitArray, 0, o);
    // 设置当前long  
    newBitArray[o] = 1L << i;
    // 拷贝剩余的  
    System.arraycopy(bitArray, o, newBitArray, o + 1, bitArray.length - o);
    bits[i4096] = newBitArray;
    // we may slightly overestimate size here, but keep it cheap
    ramBytesUsed += (newBitArray.length - bitArray.length) << 3;
  }
  ++nonZeroLongCount;
}

public boolean get(int i) {
  assert consistent(i);
  // i在第i4096个block中
  final int i4096 = i >>> 12;
  // index是第i4096个block中有效long的位图
  final long index = indices[i4096];
  final int i64 = i >>> 6;
  final long i64bit = 1L << i64;
  // 如果block还没初始化或者是block中对应的long不存在,说明i没有设置过
  if ((index & i64bit) == 0) {
    return false;
  }

  // 获取i所在的long
  final long bits = this.bits[i4096][Long.bitCount(index & (i64bit - 1))];
  return (bits & (1L << i)) != 0;
}

SparseFixedBitSet例子

public class SparseFixedBitSetDemo {
    public static void main(String[] args) {
        SparseFixedBitSet sparseFixedBitSet = new SparseFixedBitSet(300);
        sparseFixedBitSet.set(2);
        sparseFixedBitSet.set(250);
        sparseFixedBitSet.set(260);
        sparseFixedBitSet.set(67);
        sparseFixedBitSet.set(259);
    }
}
构造函数
SparseFixedBitSet sparseFixedBitSet = new SparseFixedBitSet(300);

构造函数会根据参数300计算最多需要多少个block,每个block可以容纳4096,因此在例子中我们只需要一个block就够。初始化过程中,indices数组就只有一个元素,并且值是0表示目前block中没有任何的long,对应的,bits数组中的0号block指向了一个空数组。

SparseFixedBitSet_2.png

后面为了方便起见,不考虑扩容的情况。这里统一说明下,在第一次初始化bits中对应block的数组时,大小是1,后面都是按1.5倍扩容。

设置2
sparseFixedBitSet.set(2);
  1. 定位到2在哪个block:2 >>> 12 = 0,所以2属于block 0。注意,此时indices[0]为0,说明block 0中还没有任何数据。
  2. 定位到2在block中的哪个long: 2 >>> 6 = 0,所以2在第0个long中。
  3. 计算在long中位置的掩码: 1 << 2 = 0b00000100
  4. 因为2是当前long中第一个有效位,所以直接把0b00000100设置到bits[0][0]中,并且indices中的第0位设置为1,表示block 0中存在第0个long。
  5. 结果如下:

SparseFixedBitSet_3.png

设置250
sparseFixedBitSet.set(250);
  1. 定位到250在哪个block:250 >>> 12 = 0,所以250属于block 0。
  2. 定位到250在block中的哪个long: 250 >>> 6 = 3,所以250在第3个long中。
  3. 计算在long中位置的掩码: 1 << 250 = 0b00000100_00000000_00000000_00000000_00000000_00000000_00000000_00000000
  4. 因为250是当前long中第一个有效位,所以直接把1 << 250设置到bits[0][1]中,并且indices中的第3位设置为1,表示block 0中存在第3个long。
  5. 结果如下:

SparseFixedBitSet_4.png

设置260
sparseFixedBitSet.set(260);
  1. 定位到260在哪个block:260 >>> 12 = 0,所以260属于block 0。
  2. 定位到260在block中的哪个long: 250 >>> 6 = 4,所以260在第3个long中。
  3. 计算在long中位置的掩码: 1 << 260 = 0b00010000
  4. 因为250是当前long中第一个有效位,所以直接把0b00010000设置到bits[0][2]中,并且indices中的第4位设置为1,表示block 0中存在第4个long。
  5. 结果如下:

SparseFixedBitSet_5.png

设置67
sparseFixedBitSet.set(67);
  1. 定位到67在哪个block:67 >>> 12 = 0,所以67属于block 0。
  2. 定位到67在block中的哪个long: 67 >>> 6 = 1,所以67在第1个long中。
  3. 计算在long中位置的掩码: 1 << 67 = 0b00001000
  4. 这里需要注意,因为block中的long是按顺序排的,bits[0][2]挪到bits[0][3],bits[0][1]挪到bits[0][2],把bits[0][1]空出来。
  5. 因为67是当前long中第一个有效位,所以直接把0b00001000设置到bits[0][1]中,并且indices中的第1位设置为1,表示block 0中存在第1个long。
  6. 结果如下:

SparseFixedBitSet_6.png

设置259
sparseFixedBitSet.set(259);
  1. 定位到259在哪个block:259 >>> 12 = 0,所以259属于block 0。

  2. 定位到259在block中的哪个long: 259 >>> 6 = 4,所以259在第4个long中。

  3. 计算在long中位置的掩码: 1 << 259 = 0b00001000

  4. 此时indices[0] & 1 << 4 != 0,说明第4个long已经存在。

  5. 计算第4个long在bits中的下标,Long.bitCount(indices[0] & ( (1 << 4) - 1)) = 3

  6. bits[0][3] | 0b00001000

  7. 结果如下:

SparseFixedBitSet_7.png

如何选择是使用FixedBitSet 还是 SparseFixedBitSet

从例子中看到,如果完全没有有效位的long,实际上没有占用存储空间,比如例子中的第2个long。那有人说,你只是减少了一个long空间的占用,但是额外使用了indices数组和bits数组,不是也增加空间了吗?

所以,对于是使用FixedBitSet和使用SparseFixedBitSet,是有个判断条件的:

// 该方法在 FixedBitSet和SparseFixedBitSet的父类抽象类BitSet中
public static BitSet of(DocIdSetIterator it, int maxDoc) throws IOException {
  // cost就是有效位
  final long cost = it.cost();
  // 这个就是判断使用 FixedBitSet 或者 SparseFixedBitSet 的条件
  final int threshold = maxDoc >>> 7;
  BitSet set;
  if (cost < threshold) {
    set = new SparseFixedBitSet(maxDoc);
  } else {
    set = new FixedBitSet(maxDoc);
  }
  set.or(it);
  return set;
}

maxDoc >>> 7 这个条件怎么来的呢,我个人没看懂,我试着自己算一下:

如果位图大小是maxDoc,则需要block数是 maxDoc >>> 12,对于SparseFixedBitSet额外需要的long的个数是2*block数(indices额外占用block个long,bits额外占用block个long)。无效位浪费的long的个数是(maxDoc - cost)>> 6,所以使用SparseFixedBitSet的条件应该是 2 * (maxDoc >>> 12) < (maxDoc - cost) >> 6,把不等式简化下,得到使用SparseFixedBitSet的条件应该是:

​ cost < maxDoc - (maxDoc >>> 5)。

SparseFixedBitSet总结

从上面对SparseFixedBitSet的介绍可以知道,SparseFixedBitSet其实只是避免了完全没有使用的long的浪费,如果说每个long都只有少量的有效位,那还是浪费了很多零碎的空间。

RoaringDocIdSet

Lucene中的RoaringDocIdSet是参考roaringbitmap.org/ 实现的,基于roaringbitmap的思想做了非常大的简化,从上文中,我们知道SparseFixedBitSet只是解决了FixedBitSet中对完全没有使用的long的空间浪费,但是如果每个long只有很少的有效位,则也存在巨大的空间浪费。

RoaringDocIdSet就是用来更进一步提高位图空间利用率的实现方案。RoaringDocIdSet也是对整个位图划分block,每个block有65536个bit。block存在两种数据结构,一种是FixedBitSet,另一种是short数组(short数组存的是偏移量,还原是需要加上block的baseId,baseId是可以计算,因为block的大小是固定的):

  • 如果block中的有效位小于等于4096,则使用short数组存储有效位
  • 如果block中的无效位小于等于4096,则使用short数组存储无效位
  • 其他情况都使用FixedBitSet来处理。

为什么分界是4096呢,4096个short刚好是65536个bit,所以以此为界。

需要注意的是,block的结构是可以转化的,也就是一开始是short数组存储有效位,当有效位大于4096之后short数组转成FixedBitSet,又当FixedBitSet中的无效位小于等于4096,则FixedBitSet转成short数组存储无效位

构建RoaringDocIdSet

RoaringDocIdSet内置了一个Builder,构建的逻辑都在Builder中。Lucene对于RoaringDocIdSet实现比较定制化,把RoaringDocIdSet当成是一个docId专用的位图,所以部分变量名都会带doc相关的。另外,Lucene要求用来构建RoaringDocIdSet的docId列表是有序的,这是为了使用short数组可以直接使用二分法查找,而不用进行排序。

核心成员变量
// 也就是位图的总大小
private final int maxDoc;
// block集合
private final DocIdSet[] sets;
// 目前的有效位总数
private int cardinality;
// 上一个有效位
private int lastDocId;
// 当前处理中的block id
private int currentBlock;
// 当前block中的有效位总数
private int currentBlockCardinality;

// short数组存储的block
private final short[] buffer;
// FixedBitSet存储的block
private FixedBitSet denseBuffer;
构造函数
public Builder(int maxDoc) {
  this.maxDoc = maxDoc;
  sets = new DocIdSet[(maxDoc + (1 << 16) - 1) >>> 16];
  lastDocId = -1;
  currentBlock = -1;
  buffer = new short[MAX_ARRAY_LENGTH];
}
关键方法
private void flush() {
  if (currentBlockCardinality <= MAX_ARRAY_LENGTH) { // block中的有效位<=4096
    if (currentBlockCardinality > 0) { // block使用short数组
      sets[currentBlock] =
          new ShortArrayDocIdSet(ArrayUtil.copyOfSubArray(buffer, 0, currentBlockCardinality));
    }
  } else {
    if (denseBuffer.length() == BLOCK_SIZE
        && BLOCK_SIZE - currentBlockCardinality < MAX_ARRAY_LENGTH) { 
        // 如果不是最后一个block,并且无效位总数小于4096
      // Doc ids are very dense, inverse the encoding
      final short[] excludedDocs = new short[BLOCK_SIZE - currentBlockCardinality];
      // 翻转denseBuffer,获取无效位  
      denseBuffer.flip(0, denseBuffer.length());
      int excludedDoc = -1;
      for (int i = 0; i < excludedDocs.length; ++i) {
        excludedDoc = denseBuffer.nextSetBit(excludedDoc + 1);
        assert excludedDoc != DocIdSetIterator.NO_MORE_DOCS;
        excludedDocs[i] = (short) excludedDoc;
      }
      sets[currentBlock] = new NotDocIdSet(BLOCK_SIZE, new ShortArrayDocIdSet(excludedDocs));
    } else {
      // Neither sparse nor super dense, use a fixed bit set
      sets[currentBlock] = new BitDocIdSet(denseBuffer, currentBlockCardinality);
    }
    denseBuffer = null;
  }

  cardinality += currentBlockCardinality;
  denseBuffer = null;
  currentBlockCardinality = 0;
}

/** Add a new doc-id to this builder. NOTE: doc ids must be added in order. */
public Builder add(int docId) {
  if (docId <= lastDocId) {
    throw new IllegalArgumentException(
        "Doc ids must be added in-order, got " + docId + " which is <= lastDocID=" + lastDocId);
  }
  // docId属于哪个block  
  final int block = docId >>> 16;
  
  if (block != currentBlock) { // 如果docId已经到了属于下一个block  
    flush();
    // 更新当前的block id  
    currentBlock = block;
  }

  if (currentBlockCardinality < MAX_ARRAY_LENGTH) { // 有效位<=4096
    // 使用short数组存储
    // 因为根据block id可以算出来一个block中的baseDocId,所以存储的都是 docId - baseDocId    
    buffer[currentBlockCardinality] = (short) docId;
  } else {
    if (denseBuffer == null) { // 说明是从short数组转过来的
      // 当前的block最大需要存储多少bit (除了最后一个,其他都是4096)
      final int numBits = Math.min(1 << 16, maxDoc - (block << 16));
      denseBuffer = new FixedBitSet(numBits);
      for (short doc : buffer) {
        // 因为根据block id可以算出来一个block中的baseDocId,所以存储的都是 docId - baseDocId  
        denseBuffer.set(doc & 0xFFFF);
      }
    }
    denseBuffer.set(docId & 0xFFFF);
  }

  lastDocId = docId;
  currentBlockCardinality += 1;
  return this;
}

public RoaringDocIdSet build() {
  // 最后一个block  
  flush();
  return new RoaringDocIdSet(sets, cardinality);
}

读取RoaringDocIdSet

RoaringDocIdSet的读取其实就是读取block,而block有FixedBitSet和short数组两种,FixedBitSet的读取前面已经介绍过了,short数组就是使用二分查找,也比较简单,代码就不贴出来了,需要注意的是short数组会封装成ShortArrayDocIdSet使用。

RoaringDocIdSet总结

RoaringDocIdSet是比SparseFixedBitSet空间利用率更高的位图实现,但是它有一些额外条件:

  • 用来构建的id必须是递增有序的
  • 必须一次性构建之后才能开始查找

但是只要了解了实现原理,如果有需要,我们可以自己基于相同思想做更通用的实现。