bitmap在很多海量数据处理的情况下会用到。一些典型的情况包括数据过滤,数据位设置和统计等。 它的引入和应用通常是考虑到海量数据的情况下,用普通的数组会超出数据保存的范围。使用这种位图的方式虽然不能在根本上解决海量数据处理的问题,但是在一定的数据范围内,它是一种有效的方法。bitmap在java的类库里有一个对应的实现:BitSet。我们会对bitmap的引入做一个介绍,然后详细分析一个bitvector的精妙实现,并在后面和java中的BitSet实现做一个对比。在本文中对bitmap, bitvector不做区分,他们表达的是同一个意思。
假设我们有一个很大的数据集合,比如说是一组数字,它是保存在一个很大的文件中。它总体的个数为400个亿。里面有大量重复的数据,如果去除重复的元素之后,大概的数据有40个亿。那么,假定我们有一台内存为2GB的机器。我们该如何来消除其中重复的元素呢?再进一步考虑,如果我们消除了重复的元素之后,怎么统计里面元素的个数并将消重后的元素保存到另外的一个结果文件里呢?
我们先来做一个大致的估计。假定数字的范围都是从0到Integer.MAX_VALUE。如果我们开一个数组来保存的话,是否可行呢?一个int数字4个字节,要保存0到Integer.MAX_VALUE个数字,那么就需要2的31次方个,也就是说2G个元素。这么一相乘,除非有8GB的内存,否则根本就保存不下来这么多数据。
现在,如果我们换一种方式,用bitmap试试呢?bitmap它本质上也是一个数组,只是用数组中间对应的位来表示一个对应的数字。假设我们用byte数组。比如说数字1则对应数组第1个元素的第一位。数字9则超出了第一个元素的8位范围,它对应第二个元素的第一位。这样依次类推,我们可以将这40亿个元素映射到这个byte数组里。一个数字对应到数组中位的关系如下图所示:
在上图中,假设i是数组中的一个字节,那么它将对应有下面的8个位。假设i是第一个字节,那么数字1就对应到第1位,后面的元素依次类推。
通过这一番讨论,我们也可以很容易得到数字和保存在数组中元素具体位之间的关系。假设有一个数字i,它对应保存的元素位置为: i / 8。假设数组为a,那么则为a[i/8]。那么它对应到a[i/8]中间的哪个位呢?它对应这个元素中的第i % 8这一位。
有了这些讨论,我们再来看bitmap的一个具体实现。
针对前面讨论的部分,bitmap主要的功能包括有一下几个方面。1. 置位(set):将某一位置为1. 2. 清楚位(clear),清楚某一位,将其置为0. 3. 读取位(get),读取某一位的数据,看结果是1还是0. 4. 容器所能容纳的位个数(size),相当于返回容器的长度。5. 被置位的元素个数(count),返回所有被置为1的位的个数。我们就一个个来分析:
首先,我们要定义一个byte数组,来保存这些数据。另外,我们也需要元素来保存里面所有位的个数和被置位的元素个数。因此,我们有如下的定义:
现在,假设我们要构造一个BitVector,我们就需要指定它的长度。它的一个构造函数可以构造成如下:
这里,指定的参数n表示有多少个数字,相当于要置多少个位。由于我们要用byte来保存,所以能保存这么多数字的byte个数为n / 8 + 1。这种长度用移位的方式来表示则为(size >> 3) + 1。右移3位相当于除以8.
前面已经提到过,set某个位的元素,需要找到元素所在的byte,然后再设置byte对应的位。而n / 8得到的就是对应byte的索引,而n % 8得到的是对应byte中的位。这部分的代码实现如下:
和我前面讨论的类似,这里不过是利用移位的方式实现同样的效果。前面bit >> 3相当于bit / 8。而bit & 7则相当于bit % 8。为什么bit & 7会相当于这个效果呢?在前面有一篇分析HashMap实现的文章里也讨论过这种手法。因为这里一个byte是8位,而8对应的二进制表示形式为1000,那么比它小1的7的二进制形式为0111。在将bit和7进行与运算的时候,所有大于第3位的高位都被置为0,之保留最低的3位。这样,最低的3位数字最小是0,最大是7.就相当于对数字8求模的运算效果。
和前面的set方法相反,这里是需要将特定的位置为0。
get这部分的代码主要是判断这一位是否被置为1。我们将这个byte和对应位为1的数字求与运算,如果结果不是0,则表示它被置为1.
count方法的实现是一个比较精妙的手法。按照我们原来的理解,如果要计算里面所有被置为1的位的个数,我们需要遍历每个byte,然后求每个byte里面1的个数。一种想当然的办法就是每次和数字1移位的数字进行与运算,如果结果为0表示该位没有被置为1,否则表示该位有被置位。这种办法没问题,不过对于每个字节,都要这么走一轮的话,相当于前面运算量的8倍。如果我们可以优化一下的话,对于大数据来说还是有一定价值的。下面是另一种高效方法的实现,采用空间换时间的办法:
这里建立了一个BYTE_COUNTS的数组。里面记录了对应一个数字1的个数。我们在bit[i] && 0xff运算之后得到的是一个8位的数字,范围从0到255.那么,问题就归结到找到对应数字的二进制表示里1的个数。比如说数字0有0个1, 1有1个1, 2有1个1,3有2个1…。在一个byte里面,最多有256种,如果我们将这256个数字对应的1个数都事先编码保存好的话,后面求这个数字对应的1个数只要直接取就可以了。
前面我们讨论的bitmap的实现实际上是摘自开源软件lucene的代码片段。它采用byte数组来做为内部数据保存的方式。各种置位的操作和运算都采用二进制移位等运算方式来实现尽可能的高效率。在java内部的类库里,实际上也有一个类似的实现。那就是BitSet。
BitSet的内部实现和BitVector的实现稍微有点不一样,它内部是采用long[]数组来保存元素。这样,每次的置位和清位操作方式就有差别。比如说置位,原来是对要置的数字除以8,现在则是除以64,相当于>> 6这中移位6次的操作。
另外,在BigSet里并没有实现求所有被置为1的元素的个数,如果要求他们的话,因为要在64位的数字范围内来找,不可能再用前面数字列表的方法来加快其统计速度,只能一位一位的运算和比较统计了。这是这种实现一个不足的地方。
BitSet的内部代码实现还有一个比较有意思的地方,我们先看这一段代码:
这是java里对应的置位实现方法。按照我们的理解,它应该是找到对应的long元素,然后再将对64取模后对应的位设置为1.可是这代码里的设置部分却如下: words[wordIndex] |= (1L << bitIndex); // Restores invariants. 这里用到了移位,但是没有对64求模。为什么呢?这样不会出错吗?在我们的理解里,如果对数字向左移位,如果超出了数字的表示范围,潜意识里就会认为那些部分被忽略掉了。这样想的话,那么这么一通移位下来不就得到个0了吗?我们后面针对这一点继续分析。
这个问题的答案并不复杂。如果我们去察看书上的定义,仔细看才发现。<< >>等这样的移位运算,实际上是循环移位效果的。也就是说,如果我一个数字向左移位到溢出了,它不是被忽略掉,而是后续会在低位继续补进。比如说我们看下面一个最简单的代码:
如果我们执行上面这一段代码,会发现实际的结果是当溢出之后又开始重新从头来显示,部分的输出结果如下所示:
现在,我们也就理解了为什么前面直接用一个左移位的运算来表示。因为这是循环的移位,相当于已经实现了求模的运算效果了。老实说,这种方式可行,不过个人觉得不太直观,还是用一个类似于求模运算的方式来表示好一些。
bitmap通过充分利用数组里面每一位的置位来表示数据的存在与否。比如说某一位设置为1,表示数据存在,否则表示不存在。通过充分利用数据的空间,它比直接利用一个数组,然后数组里面的每一个元素来表示一个数组的空间利用率高。比如说有一个同等长度的int数组,原来一个int元素用来表示一个数据,现在利用int元素的每一位,它可以表示32个元素。所以说,在一定程度上,某些数据映射、过滤等问题通过bitmap它可以处理的范围更大。当然,bitmap也受到计算机本身数据表示范围的限制,在超出一定的范围之后,我们还是需要考虑结合数据划分等手段。另外,在考虑这些数据结构的详细实现时,有很多细节的东西也会加深我们的认识,也许很多就是我们平时忽略的地方。