背景
在正式介绍什么是位图之前,我们先看个问题:
在给定的40亿个无符号整型中,如何判断是否存在目标无符号整型x?
比较直接的想法是,我们借用一些语言自带的集合工具Set,或者是用数组存储再遍历查找或者是对数据排序之后进行二分查找,这些解决方案理论上确实也能解决这个问题。但是我们来算一下,40亿 * 4字节 差不多是15Gb,所以上面问题的挑战在于如何使用适量的内存解决存在性判定。
今天我们要介绍的位图就是专门来解决这个问题的。位图从名称上 就可以看出来,是按位处理数据的。对于一个整型的32位,其中每一位用来标记一个整型是否存在,如下图所示,原来是32位表示一个数据,现在则是1位表示一个数据,相当于整个空间的使用缩减了32倍,只用差不多500Mb就可以存储40亿个整型了,这就是位图的魅力。
本文后面的内容主要介绍位图在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),所以初始化完成之后的位图结构如下:
设置2
fixedBitSet.set(2);
- 定位到2在哪个long:2 >> 6 = 0,所以2对应的位图位置应该在bits[0]中
- 计算在long中位置的掩码: 1 << 2 = 0b00000100
- 设置在long中对应的位置:bits[0] | 0b00000100
- 结果如下:
设置250
fixedBitSet.set(250);
- 定位到250在哪个long:250 >> 6 = 3,所以250对应的位图位置应该在bits[3]中
- 计算在long中位置的掩码: 1 << 250 = 0b00000100_00000000_00000000_00000000_00000000_00000000_00000000_00000000
- 设置在long中对应的位置:bits[3] | 0b00000100_00000000_00000000_00000000_00000000_00000000_00000000_00000000
- 结果如下:
设置260
fixedBitSet.set(260);
- 定位到260在哪个long:260 >> 6 = 4,所以260对应的位图位置应该在bits[4]中
- 计算在long中位置的掩码: 1 << 260 = 0b00010000
- 设置在long中对应的位置:bits[4] | 0b00010000
- 结果如下:
设置67
fixedBitSet.set(67);
- 定位到67在哪个long:67 >> 6 = 1,所以67对应的位图位置应该在bits[1]中
- 计算在long中位置的掩码: 1 << 67 = 0b00001000
- 设置在long中对应的位置:bits[1] | 0b00001000
- 结果如下:
FixedBitSet中其他的一些方法
FixedBitSet中还有一些其他的方法,比如求两个FixedBitSet的并集,交集等等,实现都比较简单,大家可自行查看。
FixedBitSet总结
从上面例子中,我们可以看到最后bits[2]是没有用到的,也就是说浪费了一个long的空间。如果在实际使用时,位图非常稀疏,则浪费的空间就比较多,因此,FixedBitSet适用于稠密型的位图场景。
SparseFixedBitSet
为了解决稀疏场景中,FixedBitSet空间利用率低的问题,Lucene中设计了SparseFixedBitSet专门应用于稀疏场景。在SparseFixedBitSet中引入了block的概念,一个block是64个long,也就是4096个bit。block中的long是按需分配,从而避免了无用long的空间浪费。
如上图所示,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指向了一个空数组。
后面为了方便起见,不考虑扩容的情况。这里统一说明下,在第一次初始化bits中对应block的数组时,大小是1,后面都是按1.5倍扩容。
设置2
sparseFixedBitSet.set(2);
- 定位到2在哪个block:2 >>> 12 = 0,所以2属于block 0。注意,此时indices[0]为0,说明block 0中还没有任何数据。
- 定位到2在block中的哪个long: 2 >>> 6 = 0,所以2在第0个long中。
- 计算在long中位置的掩码: 1 << 2 = 0b00000100
- 因为2是当前long中第一个有效位,所以直接把0b00000100设置到bits[0][0]中,并且indices中的第0位设置为1,表示block 0中存在第0个long。
- 结果如下:
设置250
sparseFixedBitSet.set(250);
- 定位到250在哪个block:250 >>> 12 = 0,所以250属于block 0。
- 定位到250在block中的哪个long: 250 >>> 6 = 3,所以250在第3个long中。
- 计算在long中位置的掩码: 1 << 250 = 0b00000100_00000000_00000000_00000000_00000000_00000000_00000000_00000000
- 因为250是当前long中第一个有效位,所以直接把1 << 250设置到bits[0][1]中,并且indices中的第3位设置为1,表示block 0中存在第3个long。
- 结果如下:
设置260
sparseFixedBitSet.set(260);
- 定位到260在哪个block:260 >>> 12 = 0,所以260属于block 0。
- 定位到260在block中的哪个long: 250 >>> 6 = 4,所以260在第3个long中。
- 计算在long中位置的掩码: 1 << 260 = 0b00010000
- 因为250是当前long中第一个有效位,所以直接把0b00010000设置到bits[0][2]中,并且indices中的第4位设置为1,表示block 0中存在第4个long。
- 结果如下:
设置67
sparseFixedBitSet.set(67);
- 定位到67在哪个block:67 >>> 12 = 0,所以67属于block 0。
- 定位到67在block中的哪个long: 67 >>> 6 = 1,所以67在第1个long中。
- 计算在long中位置的掩码: 1 << 67 = 0b00001000
- 这里需要注意,因为block中的long是按顺序排的,bits[0][2]挪到bits[0][3],bits[0][1]挪到bits[0][2],把bits[0][1]空出来。
- 因为67是当前long中第一个有效位,所以直接把0b00001000设置到bits[0][1]中,并且indices中的第1位设置为1,表示block 0中存在第1个long。
- 结果如下:
设置259
sparseFixedBitSet.set(259);
-
定位到259在哪个block:259 >>> 12 = 0,所以259属于block 0。
-
定位到259在block中的哪个long: 259 >>> 6 = 4,所以259在第4个long中。
-
计算在long中位置的掩码: 1 << 259 = 0b00001000
-
此时indices[0] & 1 << 4 != 0,说明第4个long已经存在。
-
计算第4个long在bits中的下标,Long.bitCount(indices[0] & ( (1 << 4) - 1)) = 3
-
bits[0][3] | 0b00001000
-
结果如下:
如何选择是使用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必须是递增有序的
- 必须一次性构建之后才能开始查找
但是只要了解了实现原理,如果有需要,我们可以自己基于相同思想做更通用的实现。