拿起Bit的核武器来编程(应用篇)

一切抛开业务聊技术的都是耍流氓

1.比特在Java中的应用

1.1. Modifier

如下,我们需要判断一个类的各种属性(是否abstract,是否public)的时候就需要使用到Modifier

但是我们发现Class类的getModifiers()返回的是一个int类型的变量。

public native int getModifiers();

一个变量如何表示多种不同的状态呢?没错就是比特,JDK使用int类型变量的低位分别来private,public,abstract,public,static,final等等标记量。

虚拟机规范

1.2. ThreadPoolExecutor & ReentrantReadWriteLock

JDK的线程池ThreadPoolExecutor需要原子性地维护线程池的数量和线程池的状态,并且还要保证线程安全。
这里ThreadPoolExecutor使用一个int类型的变量,高3位表示线程池状态(RUNNING,SHUTDOWN,STOP,TIDYING,TERMINATED),低29位表示线程池数量。

ThreadPoolExecutor

ThreadPoolExecutor

这里其实用到了一个解决CAS只能操作一个变量问题的常见手法:拆分单变量为多个变量


CAS问题

这个思想在ReentrantReadWriteLock中的思想是一致的。
AQS(AbstractQueuedSynchronizer)是实现并发的基础,但是AQS只能原子操作单个变量。那读写锁是怎么基于AQS做到读写锁关联的呢?

ReentrantReadWriteLock

Sync

1.3. HashMap & Integer

HashMap(JDK8)中有一段神奇的代码。主要是用来计算大于用户传进来的容量(就是变量cap)的最小2的幂指数的代码如下:

tableSizeFor

无独有偶,Integer中也有一段类型的代码

Integer

看到这,是不是有点懵逼呢?


不要慌,问题不大


我们这样来思考,大于容量的最小的2的幂指数有一个什么样的特征呢?
如下图:

  • N为任意数 > 0(那么肯定最高位为1,令最高位的offset=X)
  • 大于N的最小幂指数(m)一定有一个特征:X+1位为1,其他位都为0
  • 则M-1的特征就是<=X位上都为1

所以如果我可以构造数M-1(那也就是说把offset= X位后面的所有位变为1),那么就可以得到大于容量的最小的2的幂指数了。

下面我们来看一下计算过程:


从计算过程中看,每经过第n(1<=n<=5)轮计算,就会把后面位的2n-1位变为1,经过5轮计算就可以把最高位为1的位后面的所有位变为1了。这样只要在加上1就可以得到大于容量的最小的2的幂指数了。

我们再看几个HashMap的骚操作:


hash

如上图,把hashcode的低16位与hashcode做异或运算,使得hashcode的高低16位都参与到hash的计算过程,减少冲突。

再看一个简单的操作,HashMap中维护了一个Node类型的数组,获取值得时候,需要定位到是从哪一个Node数组中获取值,直接使用的是
tab[(n-1) & hash],这里正常情况下应该使用取模操作,即tab[hash %n]。


那为什么tab[(n-1) & hash]等价于tab[hash %n]呢?

这里的等价其实是有条件的,即n为2的幂指数

大家可以自己思考这个问题,然后留言哦!!

再留下一个思考题:

如何使用位运算判断一个数是2的幂指数?欢迎留言讨论

2.比特之大数据

大数据领域最大的特点就是数据量大,所以在存储数据方面就会使用各种比特的各种骚操作来节省存储。这其中数学就是这个骚操作的王者了。

2.1.Bitmap

redis中特供了一种特殊的数据结构Bitmap。但实际上其本质并不是一种新的数据结构,只是一种提供了各种位运算的string类型的数据结构。

2.1.1. Bitmap介绍

Bitmap

直观上理解,bitmap就是一个可自动扩容的bit数组。

基本操作

SETBIT key offset    #设置指定offset位上的值
GETBIT key offset   #获取指定offset位上的值
BITCOUNT key [start end]  #获取从start 到end位上的1的个数
BITOP operation[AND,OR,NOT,XOR] destkey key [key …]     #位运算   eg:BITOP AND destkey 20200901 20200902

2.1.2. Bitmap应用

问题1:如何判断海量用户中某个用户是否在线呢?

方案1:使用Set数据结构。存储下所有在线用户的id(int类型)。如果有10亿用户,那么其所需要的最小内存为10亿/1024/1024*4byte= 3.8GB

方案2:使用Bitmap数据结构。将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。如果有10亿用户,需要存储为10亿/8/1024/1024=119MB

结论,使用Bitmap耗费的存储是使用Set的119MB/3.8GB=3%

这里只是存储了登录状态这一种状态,如果还有其他状态的话,可以想象,使用Bitmap可以节省多少money啊!!!


问题2:在记录了一个亿的用户连续 7 天的打卡数据,如何统计出这连续 7 天连续打卡用户总数呢?

方案:我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。key 对应的集合的每个 bit 位的数据则是一个用户在该日期的打卡记录。一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。


2.1.3. Bitmap的坑

NOTICE:
以下命令会造成redis阻塞且申请到512MB内存,造成超预期的糟糕情况发生;
SETBIT mybitmap 4294967295 1

因为Bitmap是自动扩容的,所以如果SETBIT 命令传入的OFFSET过大,redis就会自动扩容,一下子分配很大的内存,导致阻塞发生。

那如果我确实需要很大的OFFSET呢?如我的用户就是有10亿用户,那怎么办呢?
有两种设计思路:

  • 方案一:提前在redis中初始化好比较大容量的bitmap,防止动态扩容
  • 方案二:拆分大Bitmap为多个小Bitmap,避免一下子扩容很大内存导致阻塞

总体来讲,方案二更好。原因有两点:1.预估总是不太准确的,预估小了还是会动态扩容,预估大了,有可能浪费内存 2.redis中如果有大key,容易造成各种问题。如主从同步延迟,删除阻塞等等。

拆分

2.2.BloomFilter

Bloom Filter是由Bloom在1970年提出的一种多哈希函数映射的快速查找算法。通常应用在一些需要快速判断某个元素是否属于集合,但是并不严格要求100%正确的场合。基于一种概率数据结构来实现,是一个有趣且强大的算法

2.2.1.BloomFilter场景

网络爬虫

一个爬虫程序在爬取网页的时候,由于网络间的链接错综复杂,蜘蛛在网络间爬行很可能会形成“环”,爬虫就会进入一个无限怪圈,找不到出路,程序出现崩溃。

所以为了避免形成“环”,就需要知道蜘蛛已经访问过那些URL,也就是如何判重。

但是这个判重不需要百分之百精确,判重错误的代价无非就是重新爬取一遍之前已经爬出的网页,不会造成什么问题,只是多消耗了一点资源。

短视频推荐

短视频在如今这个时代,需要根据每一个人的个人偏好做个性化推荐。但是有一个基本问题需要解决:推荐的内容最好不要跟用户已经看到的内容重复。

但是这个去重也有个特点就是判重也不需要百分之百正确。就是说用户如果偶尔在刷视频的时候刷到了自己曾经看到过的视频也没啥问题。

而BloomFilter就是天生为了这种场景而生的。

2.2.2.BloomFilter的特点

当Bloom Filter判断判断元素不存在时,一定不存在;当判断存在时,可能存在也可能不存在

关于BloomFilter,其他的都可以忘记,但是这个特点一定要记好。

2.2.3.BloomFilter原理

关于BloomFilter原理以及一些相关阅读直接安利下面的文章
布隆,牛逼!布谷鸟,牛逼!

这里我们简单说下跟bit相关的内容:



布隆过滤器的原理是,当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

说白了就是BloomFilter就是k个hash函数 + Bitmap组成的一系列操作集合的总称。其精确度收到几个方面的影响:1.hash函数的个数 2.hash函数的散列性 3.Bitmap的长度

2.3.HyperLogLog

HyperLogLog 是一种概率数据结构,它使用概率算法来统计集合的近似基数。而它算法的最本源则是伯努利过程

2.3.1.HyperLogLog场景

问题:海量用户的网站UV如何实现?【基数统计的常见场景】

方案:

  • 1.Set(存储空间巨大)
  • 2.Bitmap (如果要统计 1 亿 个数据的基数值,大约需要的内存:100_000_000/ 8/ 1024/ 1024 ≈ 12 M)

3.2.HyperLogLog基本使用

使用:

PFADD key element [element ...]                   添加指定元素到 HyperLogLog 中。
PFCOUNT key [key ...]                                    返回给定 HyperLogLog 的基数估算值。
PFMERGE destkey sourcekey [sourcekey ...]   将多个 HyperLogLog 合并为一个 HyperLogLog

使用直接在线实验redis命令
try.redis

2.3.2.HyperLogLog原理

原理的话,我们直接安利
Reids(4)——神奇的HyperLoglog解决统计问题

这里我们简单看一下HyperLogLog密集型存储对应的add元素的过程:


HyperLogLog-add

redis接收到HyperLogLog的PFADD命令后,

  • 1.使用hash函数将value映射为64位的bit串。
  • 2.然后把这64位的bit串切分为两个部分:高50位 + 低14位
  • 3.低14位用作寻找HyperLogLog内部的Bucket(总共16384个同,所以需要14位)的索引。然后从高50位中找出第一个位为1的位数(最大为50,所以Bucket中的位数为6位【26=64】)
  • 4.在设置桶中的值得时候要判断新值是否大于旧值,大于旧值就设置,小于就什么也不做

这个过程其实就是优化后的伯努利过程。其中的主要优化点就是分桶(bucket)。

不过这里面有一个基本问题:一个byte是8位,但是一个bucket是6位,如果使用byte数组来最大化利用空间来实现16384个bucket呢?

先来瞅一眼redis作者的实现


// 获取指定桶的计数值
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \ 
    unsigned long _fb = regnum*HLL_BITS&7; \  # %8 = &7
    unsigned long _fb8 = 8 - _fb; \
    unsigned long b0 = _p[_byte]; \
    unsigned long b1 = _p[_byte+1]; \
    target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)

// 设置指定桶的计数值
#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long _v = val; \
    _p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
    _p[_byte] |= _v << _fb; \
    _p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
    _p[_byte+1] |= _v >> _fb8; \
} while(0)

是不是没看懂?没关系啊,不慌,正常人都看不懂


其主要的难点在于byte是跨桶存储数据的,所以一个byte可能存储了两个桶的数据,需要各种骚操作才能做到这么丝滑。

下面我么使用Java中操作位的工具类BitSet来实现以下

public class RedisBucket {
    private static final int DEFAULT_CAPACITY_BITS = 16384;
    private static final short BUCKET_BITS = 6;
    private static final byte MAX_BUCKET_VALUE = 50;
    private static final byte MIN_BUCKET_VALUE = 0;
    private BitSet bitSet;
    public RedisBucket() {
        this.bitSet = new BitSet(DEFAULT_CAPACITY_BITS);
    }

    /**
     * 设置bucketIndex对应bucket上的值
     * @param bucketIndex 桶索引
     * @param bucketVal 值
     */
    public void setBucketValue(int bucketIndex, byte bucketVal) {
        if (bucketVal < MIN_BUCKET_VALUE || bucketVal > MAX_BUCKET_VALUE) {
            throw new IllegalArgumentException("bucket value must more than 0 and less than 50");
        }
        final int fromIndex = BUCKET_BITS * bucketIndex;
        BitSet valBitSet = BitSet.valueOf(new byte[]{bucketVal});
        for (int i = 0; i < BUCKET_BITS; i++) {
            this.bitSet.set(fromIndex + i, valBitSet.get(i));
        }
    }

    /**
     * 获取bucketIndex对应bucket上的值
     * @param bucketIndex 桶索引
     * @return
     */
    public byte getBucketValue(int bucketIndex) {
        final int fromIndex = BUCKET_BITS * bucketIndex;
        final int toIndex = BUCKET_BITS * (bucketIndex + 1);
        BitSet valBitSet = this.bitSet.get(fromIndex, toIndex);
        final byte[] bytes = valBitSet.toByteArray();
        if (bytes == null || bytes.length == 0) {
            return 0;
        }
        assert bytes.length == 1;
        return bytes[0];
    }
}

是不是觉得异常的清晰简单啊,当然相对于原生的c语言实现,在性能和空间表现上还是稍逊一筹的。

2.4.RoaringBitmap

2.4.1.再聊Bitmap

问题:给定含有40亿个不重复的位于[0, 232 - 1]区间内的整数的集合,如何快速判定某个数是否在该集合内?

有下面两种方案:


结论:稠密的数据集是比较适合使用Bitmap

那如果要存储非常稀疏且有可能非常大的数呢,比如使用Bitmap存储一个数字231-4,需要的存储就是(231-4)/8/1024/1024 ≈ 256MB,而实际上直接存储int的话,只需要4个字节

结论:Bitmap不适用存储稀疏的数据集

那有没有一种既可以存储稀疏的数据集又可以存储稠密的数据集,同时存储空间表现还非常优秀呢?

2.4.2.RoaringBitmap原理

RoaringBitmap的基本原理就是将一个int类型的数拆分为高低16位,高16位作为Container的索引,低16位放到Container中。Container会根据实际情况选择合适的Container,有Bitmap Container,Array Container,RunContainer。其实质还是利用拆分的思想,根据情况选择合适的数据结构。


RoaringBitmap

我们来看一下各种Container(其实无论是Container还是Bucket,都是一种拆分,只不过RoaringBitmap中叫Container而已)的含义

Container

这里前面两个Container比较容易理解。这里解释一下RunContainer中的含义。

举个例子,连续的整数序列11, 12, 13, 14, 15, 27, 28, 29会被RLE压缩为两个二元组11, 4, 27, 2,表示11后面紧跟着4个连续递增的值,27后面跟着2个连续递增的值。

由此可见,RunContainer的压缩效果可好可坏。考虑极端情况:如果所有数据都是连续的,那么最终只需要4字节;如果所有数据都不连续(比如全是奇数或全是偶数),那么不仅不会压缩,还会膨胀成原来的两倍大。所以,RoaringBitmap引入RunContainer是作为其他两种container的折衷方案。

所以RoaringBitmap的算法的难点就在于评估每一种Container的代价,然后选择一种合适的Container。比较容易评估的是Bitmap Contrainer和Array Container的代价。

若一个 Container 里面的 Integer 数量小于 4096,就用 Short 类型的有序数组来存储值。若大于 4096,就用 Bitmap 来存储值。


从上图可见,Bitmap Container和Arrray Container的转折点就是4096byte.所以当超过4096byte的时候Array Container就需要转换为Bitmap Container。

2.4.3.RoaringBitmap举个栗子

现在我们要将 821697800 这个 32 bit 的整数插入 RoaringBitmap中,整个算法流程是这样的:

821697800 对应的 16 进制数为 30FA1D08, 其中高 16 位为 30FA, 低16位为 1D08。

  • 1.我们先用二分查找法在容器数组(即 Container Array)中找到数值为 30FA 的容器(如果该容器不存在,则新建一个),从图中我们可以看到,该容器是一个 Bitmap 容器。

  • 2.找到了相应的容器后,看一下低 16 位的数值 1D08,它相当于是 7432,因此在 Bitmap 中找到相应的位置,将其置为 1 即可。

是不是很简单?然后换一个数值插入,比如说 191037,它的 16 进制的数值是 0002EA3D ,插入流程和前面的例子一样,不同的就在于, 高 16 位对应的容器是一个 Array Container,我们仍然用二分查找找到相应的位置再插入即可。

2.4.4.RoaringBitmap的应用

直接安利带你走进神一样的Elasticsearch索引机制

3.总结

当你的场景正好符合上面几个特征时,那就说明你可能需要Bit的帮忙了。

同时在运用Bit的时候要记住,数字是可以安位拆分的

你可能感兴趣的:(拿起Bit的核武器来编程(应用篇))