BitSet实现原理及源码解析

BitSet的结构原理

BitSet, 是Java对位集合抽象出的一种数据结构。它的内部维护了一个long数组,数组里的每一个元素用64位的二进制来表示,所以每一位只用来存储0,1值。
BitSet只知道给定的数字是否存在,并不能还原数字本身; 所以它一般用来做精确去重,比如布隆过滤器也是基于位数组来实现的。它的数据结构可以用下图来表示:


BitSet的words数据结构

上图整体表示的是一个long型的数组,我们命名为 words :

  1. 最上面的数字表示数组的索引,0,1,2,.....,n, 我们命名为wordIndex
  2. 中间一层是数组的long型元素, 用64位的二进制来表示
  3. 最下面的数字是64位二进制位索引,我们命名为 bitIndex

那么,从上图我们怎么知道BitSet是怎么工作的呢?我们举例来说明(我很喜欢举栗子), 假如我现在要把 65这个值存入BitSet中,并且设置成true。

  1. 我们需要算出65在words数组中的 wordIndex,也就是 "65" 应该放在数组中的第几个64位的二进制中; 怎么算呢?用将要存入的值除以64:
int wordIndex = 65 / 64 (值为1)

算出wordIndex为1,那么我们应该把1对应的64位二进制的某一位设置成 1(true)

  1. 将哪一位bit设置成 1(true)呢?我们需要算出bitIndex,怎么算?用将要存入的值对64取模:
int bitIndex = 65 % 64 (值为1)

算出bitIndex 为1,那么我们应该把第1位bit(index从0开始)设置成1。

  1. 最终我们得出BitSet的内部数据结构如下图:

我们现在不防把words里的二进制转换成long来看下是什么值:


value.png

从上图看出,wordIndex为0的二进制转换成long为0; wordIndex为1的二进制转换成long为2

long words = {0, 2}

JAVA对BitSet的实现

有了上面的原理,现在我们可以来看Java对BitSet的代码实现了,总得来说,就是对上面原理怎么代码来写,我们还是以65这个数字来讲解,首先看代码:

    BitSet bitSet = new BitSet();
    bitSet.set(65);
    bitSet.clear(65);
    // bit的大小不会变
    System.out.println(bitSet.size());

// 输出 
128
BitSet::BitSet()

首先是BitSet的构造方法,先初始化words数组,默认size为1。从宏观上来讲,这里的初始化是设置wordds数组的大小,从微观上(二进制)来讲,这里的初始化是把words里的long二进制化,并全默认为0。

private final static int ADDRESS_BITS_PER_WORD = 6;
// 1 * 64 = 64
private final static int BITS_PER_WORD = 1 << ADDRESS_BITS_PER_WORD; // 64
public BitSet() {
    //初始化words数组大小
    initWords(BITS_PER_WORD);
    sizeIsSticky = false;
}
private void initWords(int nbits) {
    words = new long[wordIndex(nbits-1) + 1];
}
//算出wordIndex,数组words的下标索引,直接用将要插入的值除以64
private static int wordIndex(int bitIndex) {
    // bitIndex / 64
    return bitIndex >> ADDRESS_BITS_PER_WORD;
}
BitSet::set(int)

接着是 set方法,就如前面所说的,想要插入新值,要先算出wordIndex,然后确保当前的words容量是否够用,最后把对就的bitIndex设置成1

public void set(int bitIndex) {
    if (bitIndex < 0)
        throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
    // 得到wordIndex
    int wordIndex = wordIndex(bitIndex);
    // 如果words容量不够,需要扩容
    expandTo(wordIndex);
    // 把1L左移 bitIndex位,再与对应的数组里的二进制取或,目的是将对应的bitIndex设置成1
    words[wordIndex] |= (1L << bitIndex); // Restores invariants
    
    checkInvariants();
}

private void expandTo(int wordIndex) {
    // 需要的words大小为索引加 1,
    int wordsRequired = wordIndex+1;
    // wordsInUse默认为0,代表当前的words的逻辑大小
    if (wordsInUse < wordsRequired) {
        // 扩容
        ensureCapacity(wordsRequired);
        wordsInUse = wordsRequired;
    }
}

private void ensureCapacity(int wordsRequired) {
    // 如果当前worfds的长度小于需要的长度
    if (words.length < wordsRequired) {
        // Allocate larger of doubled size or required size
        // 分配策略是,2倍当前的size与 wordsRequired的最大值
        int request = Math.max(2 * words.length, wordsRequired);
        // 数组扩容
        words = Arrays.copyOf(words, request);
        sizeIsSticky = false;
    }
}

上面的代码比较难理解的是words[wordIndex] |= (1L << bitIndex);。这句代码就如我上面提到的对 64取模得到bitIndex(这里的bitIndex并非方法参数里的bitIndex,可以理解为分片后的bitIndex),然后把对应的bitIndex设置成1(true) 这也是最重要的逻辑。那么它是怎么把bitIndex设置成1(true)的呢?
先看1L<<bitIndex:

    System.out.println(Long.toBinaryString(1L<<0));
    System.out.println(Long.toBinaryString(1L<<1));
    System.out.println(Long.toBinaryString(1L<<2));
    System.out.println(Long.toBinaryString(1L<<3));
    System.out.println(Long.toBinaryString(1L<<4));
    System.out.println(Long.toBinaryString(1L<<5));
    System.out.println(Long.toBinaryString(1L<<6));
    System.out.println(Long.toBinaryString(1L<<7));
    System.out.println("........");
    System.out.println(Long.toBinaryString(1L<<63));
    System.out.println(Long.toBinaryString(1L<<64));
    System.out.println(Long.toBinaryString(1L<<65));

//------------------------
// 输出如下:
1L<<0 => 1
1L<<1 => 10
1L<<2 => 100
1L<<3 => 1000
1L<<4 => 10000
1L<<5 => 100000
1L<<6 => 1000000
1L<<7 => 10000000
........
1L<<63 => 1000000000000000000000000000000000000000000000000000000000000000
1L<<64 => 1
1L<<65 => 10
1L<<66 => 100
1L<<67 => 1000
........
1L<<127 => 1000000000000000000000000000000000000000000000000000000000000000
1L<<128 => 1

看出什么规律了吗?1 左移多少位,对应了1的位置, 左移溢出时,从头开始,64位一个轮回,这不就是前面提到的 value % 64吗? 这样就算出了bitIndex。
现在有了wordIndex和bitIndex,那怎么把bitIndex设置成1(true)呢?words[wordIndex] = words[wordIndex] | bitIndex 就是把bitIndex设置成1,分别把words[wordIndex] = 0(以65为例,原来的值为0的),与bitIndex=1(1 << 65 = 1) 分别转换成64位二进制,做或操作试试:

0000000000000000000000000000000000000000000000000000000000000000 
| (或操作)
0000000000000000000000000000000000000000000000000000000000000010 
= (等于)
0000000000000000000000000000000000000000000000000000000000000010
BitSet::clear(int)

有插入,肯定也要有去除(设置成0 false),BitSet的clear和插入一样,也需要得到wordIndex和bitIndex。

public void clear(int bitIndex) {
    if (bitIndex < 0)
        throw new IndexOutOfBoundsException("bitIndex < 0: " + bitIndex);
    // 得到wordIndex,如果大于当前的words的大小,则不需要remove(压根就没有插入过)
    int wordIndex = wordIndex(bitIndex);
    if (wordIndex >= wordsInUse)
        return;
    // 得到bitIndex后,需要对它取反再后原来的word做与操作,因为要确保原来的值(已经设置成1的值)不被抹去(1 & 1 = 1)。
    words[wordIndex] &= ~(1L << bitIndex);
    // 从words的最后一个元素开始判断,重新设置wordsInUse
    recalculateWordsInUse();
    checkInvariants();
}

private void recalculateWordsInUse() {
    // Traverse the bitset until a used word is found
    int i;
    for (i = wordsInUse-1; i >= 0; i--)
        // 如果最后一个元素不为0,则直接跳出循环。
        if (words[i] != 0)
            break;
    // size = index + 1(index从0开始)
    wordsInUse = i+1; // The new logical size
}

与插入不同的是,需要对算出的bitIndex取反,再与words[wordIndex]做与(&)操作。具体分析,可以参照插入时,自己做与操作看看,我就不必多说了。值得一提的是,当clear之后,bitsize不会变,当扩容之后,做clear操作,bitsize还是扩容后的大小,比如上面的 words大小为2,那么就有2个 64位的 long,那么大小还是128。

总结

以上就是我对BitSet的实现原理的分析,如果有什么不对的地方还望指出。BitSet(bitmap)的使用场景还是蛮多的,常见的如:对海量数据的查找,去重,排序(因为里面的数据本身就是有序的)等。相比于其它集合,BitSet采用的是纯bit实现,占用更少的空间;在我最近的工作中,我就在Flink中使用了BitSet对海量数据的累计去重,以每天每小时的结构滚动输出到mysql。全篇完(:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353