基本介绍
位图 比较常见的数据结构 核心是为了解决去重场景的大数据存储问题本质其实就是哈希表的一种实现形式 使用每个位来表示某个数字
例子:
假设有个1,3,5,7的数字集合,如果常规的存储方法,要用4个Int的空间。其中一个Int就是32位的空间。3个就是4*32Bit,相当于16个字节。
如果用Bitmap存储呢,只用8Bit(1个字节)就够了。bitmap通过使用每一bit位代表一个数,位号就是数值,1标识有,0标识无。如下所示
简单实现
public class BitMap {
/**
* 字节数组存储bitmap
* */
private byte[] bits;
/**
* size
* */
private int bitSize;
public BitMap(int bitSize) {
this.bitSize = (bitSize>>3)+1;
this.bits = new byte[this.bitSize];
}
public void add(int num){
//在数组的哪个位置
int arrIndex=num>>3;
//在字节数组的位置==arrIndex%8
int pos=num&0x07;
bits[arrIndex] |= 1 << pos;
}
public boolean contain(int num){
int arrIndex=num>>3;
int pos=num&0x07;
//判断元素是否存在
return (bits[arrIndex] & (1 << pos)) != 0;
}
public void clear(int num){
int arrIndex=num>>3;
int pos=num&0x07;
//取反后& 清除对应元素
bits[arrIndex] &= ~(1 << pos);
}
public static void printBit(BitMap bitMap) {
int index=bitMap.bitSize & 0x07;
for (int j = 0; j < index; j++) {
System.out.print("byte["+j+"] 的底层存储:");
byte num = bitMap.bits[j];
for (int i = 7; i >= 0; i--) {
System.out.print((num & (1 << i)) == 0 ? "0" : "1");
}
System.out.println();
}
}
// 输出数组元素,也可以使用Arrays的toString方法
private static void printArray(int[] arr) {
System.out.print("数组元素:");
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]+" ");
}
System.out.println();
}
public static void main(String[] args) {
// 简单试验
BitMap bitmap = new BitMap(3);
bitmap.add(3);
System.out.println("插入3成功");
boolean isexsit = bitmap.contain(3);
System.out.println("3是否存在:" + isexsit);
printBit(bitmap);
bitmap.clear(3);
isexsit = bitmap.contain(3);
System.out.println("3是否存在:" + isexsit);
printBit(bitmap);
}
}
输出结果
插入3成功
3是否存在:true
byte[0] 的底层存储:00001000
3是否存在:false
byte[0] 的底层存储:00000000
java-BitSet
JAVA BitSet源码理解
- 这个类实现了一个根据需要增长的位向量。位集的每个组件都有一个布尔值。BitSet的位由非负整数索引。可以检查、设置或清除单个索引位。一个位集可用于通过逻辑AND、逻辑inclusive OR和逻辑exclusive OR操作修改另一个位集的内容。
- 默认情况下,集合中的所有位最初的值都为false。
- 每个BitSet都有一个当前大小,即BitSet当前使用的空间位数。请注意,大小与BitSet的实现有关,因此它可能会随着实现而改变。BitSet的长度与BitSet的逻辑长度有关,并且与实现无关。
- 除非另有说明,否则将null参数传递给位集中的任何方法都将导致NullPointerException。
- 如果没有外部同步,BitSet对于多线程使用是不安全的。
核心代码片段
首先可以看到源码中,最核心的属性信息。在BitSet 中使用的是long[] 作为底层存储的数据结构,并通过一个 int 类型的变量,来记录当前已经使用数组元素的个数。
这个方法是用来确定当前要设置的这个值在long数组中的arrIndex
private static final int ADDRESS_BITS_PER_WORD = 6;
private static int wordIndex(int bitIndex) {
return bitIndex >> ADDRESS_BITS_PER_WORD;
}
构造函数
private static final int BITS_PER_WORD = 1 << ADDRESS_BITS_PER_WORD;
public BitSet() {
initWords(BITS_PER_WORD);
sizeIsSticky = false;
}
private void initWords(int nbits) {
//默认构造
words = new long[wordIndex(nbits-1) + 1];
}
set()
public void set(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex);
expandTo(wordIndex); //判断是不是需要扩容
words[wordIndex] |= (1L << bitIndex); // 等价于 words[wordIndex] |= (1L << bitIndex&(63))
checkInvariants(); //一些校验
}
clear() 清除某个位的值
public void clear(int bitIndex) {
if (bitIndex < 0)
throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
int wordIndex = wordIndex(bitIndex);
if (wordIndex >= wordsInUse) //wordsInUse-->long[].length
return;
words[wordIndex] &= ~(1L << bitIndex); // 清除当前位置的数
recalculateWordsInUse(); //重新计算wordsInUse
checkInvariants();
}
扩容
private void ensureCapacity(int wordsRequired) {
if (words.length < wordsRequired) {
// Allocate larger of doubled size or required size 2倍扩容
int request = Math.max(2 * words.length, wordsRequired);
words = Arrays.copyOf(words, request); //数据迁移
sizeIsSticky = false;
}
}
bitMap小结
bitmap的优势:
-
存储成本极低 面对海量的数据存储能大大的节约成本 当需要存储一些很大,且无序,不重复的整数集合那么使用BitMap这种数据结构是非常合适的、
-
因为其天然去重的属性,对于需要去重存储的数据很友好!因为bitmap每个值都只对应唯一的一个位置,不能存储两个值,所以Bitmap结构天然适合做去重操作
-
同样因为其下标的存在,可以快速定位数据!
-
还有因为其类集合的特性,对于一些集合的交并集等操作也可以支持!比如想查询[1,2,3]与[3,4,5] 两个集合的交集,用传统方式取交集就要两层循环遍历。而Bitmap实现的底层原理,就是把01110000和00011100进行与操作就行了。而计算机做与、或、非、异或等等操作是非常快的
正所谓没有最好的数据结构 只有最合适的 bitMap也有他的劣势
bitMap的劣势:
- 只能存储整数而不是其他的数据类型
- 不适合存储稀疏的数据例如 [0,1,9999999] 因为如果这种结构的话也就违背了 位图节约空间的根本目的 这样存储是非常不划算的 空间利用率很低
- 不适用于存储重复的数据
bitMap优化--RoaringBitmap
BitMap的优化
优化思路
-
针对存储非正整数的类型,如字符串类型的,可以考虑将字符串类型的数据利用类似hash的方法,映射成整数的形式来使用bitmap,但是这个方法会有hash冲突的问题,解决这个可以优化hash方法,采用多重hash来解决,但是根据经验,这个效果都不太好,通常的做法就是针对字符串建立映射表的方式
-
针对bitmap的优化最核心的还是对于其存储成本的优化,毕竟大数据领域里面,大多数时候数据都是稀疏数据,而我们又经常需要使用到bitmap的特长,比如去重等属性,所以存在一些进一步的优化,比较知名的有WAH、EWAH、RoaringBitmap等,其中性能最好并且应用最为广泛的当属RoaringBitmap(咆哮位图)
RoaringBitmap核心原理
1个Int 类型相当于有32 bit 也就相当于2^32=2^16 x 2^16,这意味着任意一个Int 类型可以拆分成两个16bit的来存储,每一个拆出来的都不会大于2^16, 2^16就是65536,而Int的正整数实际最大值为 2147483647。而RoaringBitmap的压缩首先做的就是用原本的数去除65536,结果表示成(商,余数),其中商和余数是都不会超过65536。
举例: 156680的底层数据存储
www.processon.com/diagraming/…
RoaringBitmap底层存储结构
Container的三种不同的类型
- ArrayContainer 存储的方式就是 shot类型的数组, 每个数字占16bit 也就是2Byte,当id 数达到4096个时,占用4096x2 = 8196byte 也就是8kb,而id数最大是65536,占用 65536x2 =131072 byte 也就是128kb。
- BitmapContainer存储的方式就是bitmap类型,bitmap的位数为 65536,能存储0~65535个数字,占用 65536/8/1024=8kb,也就是bitmap container占用空间恒定为8kb。
- RunContainer存储的必须是连续的数字,比如存储1,2,3...100w,RunContainer就只会存储[1,100w]也就是开头和结尾的一个数字,其压缩效率取决于连续的数字有多长。最坏的情况就是当前的所有的数字都不是连续的 65536*2/1000≈128KB 最佳的情况 就是short[].length=2 也就是所有数据都是连续的 也就是占用 4B
数据写入流程
<dependency>
<groupId>org.roaringbitmap</groupId>
<artifactId>RoaringBitmap</artifactId>
<version>0.9.30</version>
</dependency>
public static void t1() {
RoaringBitmap rr = RoaringBitmap.bitmapOf(1, 2, 3,4,5, 1000);
RoaringBitmap rr2 = new RoaringBitmap();
rr2.add(1L, 100000000L);
// 第三个数值,索引从0开始
int thirdvalue = rr.select(3);
// 2这个值的排序,排序索引从1开始,如果不在是0
// int select9 = rr.select(9);
int indexoftwo = rr.rank(1000);
boolean c1 = rr.contains(1000);
boolean c2 = rr.contains(7);
System.out.println("bofore or, rr is: " + rr);
// System.out.println("bofore or, rr2 is: " + rr2);
System.out.println("thirdvalue is: " + thirdvalue);
System.out.println("indexoftwo is: " + indexoftwo);
System.out.println("c1 is: " + c1);
System.out.println("c2 is: " + c2);
System.out.println();
// 做并集
RoaringBitmap rror = RoaringBitmap.or(rr, rr2);
RoaringBitmap rrand = RoaringBitmap.and(rr, rr2);
// rr.or(rr2);
System.out.println("rr is: " + rr);
// System.out.println("rr2 is: " + rr2);
// System.out.println("rror is: " + rror);
System.out.println("rrand is: " + rrand);
boolean equals = rror.equals(rr);
System.out.println("is equals: " + equals);
// 获取位图中元素个数
long cardinality = rr.getLongCardinality();
System.out.println("cardinality is: " + cardinality);
}
bitMap的应用
HOW
bitmap就像一柄双刃剑,用的好可以帮助我们破除瓶颈,解决痛点。用的不好不仅会丢失它所有的优点,还要搭上过多的存储,甚至会丧失掉最重要的准确性,所以要针对不同业务场景灵活使用我们的武器,才能事半功倍!
bitMap在对用户分群的使用
假设我们为了做数据统计有这么一张用户的标签的宽表
| 用户ID | 城市ID | 是否下单 | 是否老用户 | 是否有xx标签 | 是否有yy标签 | 是否有zz标签 | 是否有uu标签 | 是否有vv标签 |
|---|---|---|---|---|---|---|---|---|
| 1 | 1001 | 0 | 1 | 1 | 0 | 0 | 1 | 1 |
| 2 | 1001 | 1 | 0 | 1 | 1 | 0 | 0 | 1 |
| 3 | 1002 | 0 | 1 | 0 | 0 | 1 | 1 | 0 |
| 4 | 1003 | 0 | 1 | 0 | 1 | 1 | 0 | 0 |
| 5 | 1004 | 1 | 0 | 1 | 0 | 1 | 1 | 0 |
如果想根据标签划分人群,比如想查询1001城市且下单且拥有xx标签的人群。
传统做法:
通常是对列值进行遍历筛选,如果优化也就是列上建立索引,但是当这张表有很多标签列时,如果要索引生效并不是每列有索引就行,要每种查询组合建一个索引才能生效,索引数量相当于标签列排列组合的个数,当标签列有1、2 k 的时候,这基本就是不可能的。通常的做法是在数仓提前将热点的组合聚合过滤成新字段,或者只对热点组合加索引,但这样都很不灵活。
使用BitMap优化:
| 标签 | 标签值 | 底层二进制存储结构 |
|---|---|---|
| 城市(城市ID) | 1001 | 00000110 |
| 城市(城市ID) | 1002 | 00001000 |
| 城市(城市ID) | 1003 | 00010000 |
| 城市(城市ID) | 1004 | 00100000 |
| 是否下单 | 0 | 00011010 |
| 是否下单 | 1 | 00100100 |
| 是否老用户 | 0 | 00100100 |
| 是否老用户 | 1 | 00011010 |
| 是否有标签xx | 0 | 00011000 |
| 是否有标签xx | 1 | 00100110 |
| ........ |
空间&时间复杂度分析:
- 时间复杂度:
上面我们想查询1001城市并且下单且有标签xx的目标人群只需要处理=>(00000110&00100100&00100110) 即可 这样的位运算计算机处理起来是相当快的
- 空间复杂度:
下面我们假设现在有一亿的用户数据 且我们国家的城市共有660个 我们有标签20个也就是我们需要700个位图来存储这些数据 那么我们的空间复杂度是多少呢? 假设我们使用的是bitMapContainer 假设65535个桶都被占满 那么short[]=65535*2/1024+8=135KB≈0.1M 700个就是70M的存储空间
BloomFliter VS 布谷鸟过滤器?
Ext: BloomFliter VS 布谷鸟过滤器?
布隆过滤器核心原理:
布隆过滤器的两大特点:
- 只要返回数据不存在,则肯定不存在。
- 返回数据存在,但只能是大概率存在。
其实位图算是布隆过滤器的一个实现的版本 只不过bitMap不会出现误判的情况但是随之带来的弊端就是需要存储的位数(bitCount)随着最大的那一个ID的变化而随之增大
www.jasondavies.com/bloomfilter…
简单的使用:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.0-jre</version>
</dependency>
public static void main(String[] args) {
int total = 1000000; // 总数量
BloomFilter<CharSequence> bf =
BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), total,0.001);
// 初始化 1000000 条数据到过滤器中
for (int i = 0; i < total; i++) {
bf.put("" + i);
}
// 判断值是否存在过滤器中
int count = 0;
for (int i = 0; i < total + 10000; i++) {
if (bf.mightContain("" + i)) {
count++;
}
}
System.out.println("已匹配数量 " + count);
}
缺点 :不支持删除元素 不支持计数 空间利用率低下 查询性能较弱
产生上述缺点的主要原因:
- 根据我们的误判率设置的值越小所需要的bit位就越多 hash函数就会越多 查询->随机读内存 无法很好的利用CPU缓存
- 不可以直接删除元素因为可能这个位置被别的元素使用 可以使用countingBloomFilter 但是空间会被撑大到3-4倍
| 数据量 | fpp | 实际使用的位数 | hash函数的个数 | |
|---|---|---|---|---|
| 20000 | 0.01 | 191701 | 7 | |
| 200000 | 0.01 | 1917011 | 7 |
可见 在误判率=0.01 的时候实际使用的bit位数大概为数据量的10倍左右
布谷鸟过滤器
布谷鸟过滤器
背景:
为了解决上面所说的布隆过滤器的问题 布谷鸟过滤器应运而生 源自一篇论文《Cuckoo Filter:Better Than Bloom》作者将布谷鸟过滤器和布隆过滤器进行了深入的对比 通篇就是一个主题:Cuckoo Filter:Better Than Bloom
-
布谷鸟过滤器的核心思想:
- 使用两个哈希函数 h1(x) 、 h2(x) 和两个哈希桶 T1、T2 。
插入元素 x:
- 如果 T1[h1(x)] 、T2[h2(x)] 有一个为空,则插入;两者都空,随便选一个插入。
- 如果 T1[h1(x)] 、T2[h2(x)] 都满,则随便选择其中一个(设为 y ),将其踢出,插入 x。
- 重复上述过程,插入元素 y。
- 如果插入时,踢出次数过多,则说明哈希桶满了。则进行扩容、ReHash 后,再次插入。
查询元素 x:
- 读取 T1[h1(x)] 、T2[h2(x)] 和 x 比对即可
演示:www.lkozma.net/cuckoo_hash…
关键点:
-
每个位置上存储的信息是什么? 是当前这个数据全部的信息吗?
-
当这个位置上的元素被剔除的时候他怎么找到自己的另一个位置?
首先每个槽位存储的并不是每个数据的全部信息 而是一个指纹信息或者说一个特征值 下面我们叫做fp
- 如何根据fp(x) 和他其中的一个位置计算出另一个位置?
-
h2 = h1 ⊕ hash(f); //这里为什么不直接使用fp 而是hash(fp) 因为这里的指纹信息只有8bit 如果按照这种方法,从物理意义上理解,即是在原位置±2^8 = 256 的范围内找到另一个位置,因为异或只会改变低 8 bit 的值。这个范围太小了,不利于均衡散列。 // f = fingerprint(x); i1 = hash(x); i2 = i1 ⊕ hash( f);
当然如果两个数据满足hash(x)==hash(y)&&fp1=fp2 那么就会出现假阳性
除此之外 我们还可以发现我们并没有计算出来的hash进行%Length的操作 取而代之的而是取出hash值的最后N位,这也就是说 布谷鸟过滤器限制了哈希表的长度为 2的指数次幂 但是bloomFilter没有这个限制 也就是说其实布谷鸟的可伸缩性其实是不如布隆过滤器的
变种
变种
其实我们可以看出以上面这种结构来看的话 在最完美的情况下(没有发生哈希冲突) 的空间利用率大概是50% 这显然是不能接受的 那么可以怎样优化呢?
其中a,b 只不过是去掉了第二个hash桶 核心还是踢来踢去 空间利用率也不会太高 甚至还会造成频繁的rehash resize
第二种 直接将空间利用率提升到了98%
一切看上去都是很美好 但是没有完美的数据结构 就像下面这个论文的最后一段话所说的
对重复数据进行限制:如果需要布谷鸟过滤器支持删除,它必须知道一个数据插入过多少次。不能让同一个数据插入 kb+1 次。其中 k 是 hash 函数的个数,b 是一个下标的位置能放几个元素。
比如 2 个 hash 函数,一个二维数组,它的每个下标最多可以插入 4 个元素。那么对于同一个元素,最多支持插入 8 次。
加入超过了这个数字 就会出现循环踢出的情况 ,为了避免这种情况的出现我们就需要记录每个数据出现的次数
但是如果数据量很大的话这个记录的开销也是不容小觑的 但是如果我们想使用布谷鸟过滤器的删除操作 那么我们就不得不记录这些东西
总结
布谷鸟过滤器论文:www.cs.cmu.edu/~dga/papers… 可以看一下这两个过滤器的性能对比以及空间使用率的对比图
指纹信息的bit数量和负载之间的关系
| bloomFilter | 布谷鸟filter | |
|---|---|---|
| 是否可删除 | N | Y |
| 伸缩性 | 较好 | 较差 2倍扩容 |
| 空间利用率 | 较差 | 较好 |
| 查询效率 | 取决于 数据量 与误判率 【较差】 | 较好 只有两个hash函数 数据不后悔过分散列 |
/**
* 实现一个布谷鸟过滤器
*
* 1 两个hash函数
* 2 两个hashTable
* 3 存储指纹信息 8 Bit
* 4 亦或
* */
public class CuckooFilter<T> {
private Byte[] bits;
private Byte[] bits2;
private Integer MAX_LOOP=16;
private Integer size;
public CuckooFilter(int size){
//2的指数次幂
int n = size - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
this.size=n;
bits=new Byte[n];
bits2=new Byte[n];
}
public void put(T val){
Integer i=0;
Boolean isSuccess=false;
while (!isSuccess) {
int i1 = hash1(val) & (size - 1);
if(bits[i1]!=null){
int i2 = hash2(val) & (size - 1);
if(bits2[i2]!=null){
//随机剔除一个
Byte oldVal=bits2[i2];
bits2[i2]=fingerprint(val);
//find
KickAndOcc(val,oldVal,i2,i,isSuccess);
}else{
isSuccess=true;
bits2[i2]=fingerprint(val);
}
}else{
isSuccess=true;
bits[i1]=fingerprint(val);
}
}
if(i>=MAX_LOOP&&!isSuccess){
throw new RuntimeException("没找到");
}
}
public boolean contain(T val){
return bits[(hash1(val)&size-1)]!=null||bits[(hash2(val)&size-1)]!=null;
}
public void del(T val){
if(bits[(hash1(val)&size-1)]!=null){
bits[(hash1(val)&size-1)]=null;
}else if(bits[(hash2(val)&size-1)]!=null){
bits[(hash2(val)&size-1)]=null;
}else{
throw new RuntimeException("不包含此元素");
}
}
public void KickAndOcc(T newVal,Byte oldVlaFire,int h,Integer loop,Boolean isSuccess){
loop++;
if(loop>MAX_LOOP){
System.out.println("loop==>"+loop+"new Val==>"+newVal);
throw new RuntimeException("insert error should ensure capcity");
}
if(bits2[otherPos(oldVlaFire,h)]==null){
isSuccess=true;
bits2[otherPos(oldVlaFire,h)]=fingerprint(newVal);
}else{
KickAndOcc(newVal,bits2[otherPos(oldVlaFire,h)],otherPos(bits2[otherPos(oldVlaFire,h)],otherPos(oldVlaFire,h)),loop,isSuccess);
}
}
public int hash(Byte val){
return Objects.hash(val);
}
public int otherPos(Byte fire,int h1){
return (hash(fire)^h1)&(size-1);
}
public int hash1(T val){
int hash = Objects.hash(val);
hash^=hash>>>16;
return hash;
}
public int hash2(T val){
int hash= hash1(val);
hash^=hash>>>16;
return hash;
}
public Byte fingerprint(T val){
return (byte)(val.hashCode()&((1<<9)-1));
}
private void printAll(){
for (int i = 0; i < bits.length; i++) {
if(bits[i]!=null){
System.out.println("bits==>"+i+"va=>"+bits[i]);
}
}
for (int i = 0; i < bits2.length; i++) {
if(bits2[i]!=null){
System.out.println("bits2==>"+i+"va=>"+bits2[i]);
}
}
}
public static void main(String[] args) {
CuckooFilter<String> stringCuckooFilter = new CuckooFilter<>(10);
stringCuckooFilter.put("aaa");
stringCuckooFilter.put("bbb");
stringCuckooFilter.put("ccc");
stringCuckooFilter.put("ddd");
stringCuckooFilter.put("eee");
stringCuckooFilter.put("ooo");
stringCuckooFilter.put("iii");
stringCuckooFilter.put("zzz");
stringCuckooFilter.put("ppp");
stringCuckooFilter.put("yuyu");
stringCuckooFilter.printAll();
stringCuckooFilter.printAll();
}
}