Java位运算、雪花算法及HashMap中存储及扩容的应用

1,633 阅读9分钟

一、Java位运算

在计算机系统中,不管是代码还是数字,最终都会转换为二进制即0或1,数值一律用补码来计算和存储,因为使用补码可以让加法和减法统一使用加法进行运算。

有关机器数,原码、反码、补码可以参考这篇文章:我们应该知道的java位运算

1. 左移(<<)

m<<n的含义: 把整数m表示的二进制数左移n位, 高位超出都舍弃,低位补0.  (此时将会出现正数变成负数的可能)。

2. 右移(>>)

m>>n的含义:把整数m表示的二进制数右移n位, m为正数,高位全部补0;m为负数,高位全部补1

3. 无符号右移(>>>)

m>>>n:整数m表示的二进制右移n位,不论正负数,高位都补0

4. 按位与操作(&)

两个都为1才为1,其他情况均为0

  • 1&0=0
  • 0&0=0
  • 1&1=1
  • 0&1=0

5. 按位或操作(|)

只要有一个1则为1,两个都为0才为0

  • 1|0=1
  • 0|0=0
  • 1|1=1
  • 0|1=1

6. 按位异或操作( ^ )

相同位值为0,不同为1

  • 1^1=0
  • 1^0=1
  • 0^1=1
  • 0^0=0

7. 按位非操作(~)

包括符号位,1变成0,0变成1

  • 把-5转位16位的二进制机器数:11111111 11111011
  • ~(-5) 取反结果:00000000 00000100 
  • 转为十进制,结果为4

二、总结规律并尝试应用

我们知道在cpu底层位运算要比代码直接运算要快得多,那么位运算如何运用呢,我们来总结一下规律,当一个二进制数n对0或者1做位运算的结果

输入n进行位计算与运算(&)或运算(|)异或运算(^)
00nn
1n1对n取反

由此我们得出

  1. 当我们需要把一个包含各种0、1的二进制数n一部分变成0,其他位保持不变,只需对它进行(&)与运算,范例:
10100101 & 11110000=10100000,即低位消0,用00001111可高位消0
  1. 把n一部分变成1,其他位保持不变,则对它做(|)或运算,范例:
10100101 | 11110000=11110101,即高位补1,用00001111可低位补1
  1. 因异或相同为0,不同为1,故常用与在一堆数中找重复数字,或对数字进行随机变化(hashmap获取hash值时将key的原始hash值的低16位与高16位进行异或运算使高位也参与到索引的运算中),下面应用时会讲。

三、雪花算法的应用

我所知Twitter的雪花算法SnowflakeIdWorker即使用long的64位数字进行位运算做的一个分布式id解决方案。

可以参考这篇文章:分布式ID神器之雪花算法

一个long类型的数字共64位。

  1. 第一位始终是0,即正数,没有实际作用。 

  2. 时间戳,java的时间戳与unix原生不同,精确到毫秒,占用41bit,总共可以容纳约69年的时间。

  3. 工作机器id 占用10bit,其中高位5bit是数据中心ID,低位5bit是工作节点ID,做多可以容纳1024个节点。

  4. 序列号 占用12bit,每个节点每毫秒0开始不断累加,最多可以累加到4095,一共可以产生4096个ID。

Long idwork = timestamp << 22 | this.datacenterId << 17 | this.workerId << 12 | this.sequence;

其中timestamp为时间戳,当前时间戳为
00000000 00000000 00000001 01111111 10000010 10100110 01001000 10001001
向左移动22位得到
01011111 11100000 10101001 10010010 00100010 01000000 00000000 00000000

datacenterId为数据中心id,只有5位,为0-31,左移17位后得到即:
00000000 00000000 00000000 00000000 00000000 00111110 00000000 00000000

workerId为节点数id,也是5位,0-31,左移17位后得到即:
00000000 00000000 00000000 00000000 00000000 00000001 11110000 00000000

sequence为同一节点1毫秒内的自增数字,占位12bit,取值范围0-4095
00000000 00000000 00000000 00000000 00000000 00000000 00001111 11111111

对他们进行(|)或运算
01011111 11100000 10101001 10010010 00100010 01000000 00000000 00000000
00000000 00000000 00000000 00000000 00000000 00111110 00000000 00000000
00000000 00000000 00000000 00000000 00000000 00000001 11110000 00000000
00000000 00000000 00000000 00000000 00000000 00000000 00001111 11111111

经过上面的规律总结,我们知道0对其他数进行或运算都是它自己,即上面几个数进行或运算后相当于累加。
01011111 11100000 10101001 10010010 00100010 01111111 11111111 11111111
即可得到分布式id值。

四、HashMap中的应用

先总结一下结果,hashMap存储过程,其中hashMap为数组+链表+红黑树的存储方式

(1)初始化时可指定hashmap容量,会被向上取2的幂次方,并在第一次put方法时初始化此容量

(2)若未指定容量,则在第一次put时初始化容量为16

(3)初始化容量为数组容量,即Node<K,V>[] table,若发生hash取模冲突则挂在链表下,即table[i].next

(4)hashCode会使用扰动算法,使高低Bit都参与到Hash的计算中,减少hash冲突

(5)用hashCode对数组容量进行取模(实际为与预算),获取索引下标位置,若冲突则调用equals()方法,若相等则覆盖,不相等挂在链表下

(6)若链表长度超过8则转化为红黑树

(7)若存储元素容量size超过数组容量table.length的0.75倍(默认值,可修改),则进行扩容

(8)扩容时重新计算索引下标值,元素要么在原位置,要么在原位置+旧桶容量的位置,其中链表会被均匀分散

1. 初始化容量获取2的幂次方

在new一个hashmap对象时可以指定集合的容量,但是你指定的容量就是容器初始化的容量吗?答案是否。

我们看一下初始化的源码

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

其中initialCapacity是外界指定的容量,loadFactor是负载因子,默认为0.75,即每次size达到table容量的0.75倍时进行扩容。我们看到initialCapacity经过了tableSizeFor()的转换。

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

我们可以看到这段代码是对初始容量进行移位运算和或运算,这段代码的结果是对输入的值取向上的2的幂次方,如输入10返回16,输入18返回32,若本身为2的幂次方则返回原值。

我们知道int是32位,上面的或等于运算,是将当前数字的第一位为1的数字之后都变成1,再对结果进行+1后即变成2的整数次幂返回
如输入18,18-1=17,即
00000000 00000000 00000000 00010001

n |= n >>> 1;
n >>> 1即
00000000 00000000 00000000 00001001
二者进行或运算之后再赋值给n
即
00000000 00000000 00000000 00011001

n |= n >>> 2;
得到
00000000 00000000 00000000 00011111

我们看到后面的数字都变成了1,再经过后面的或等于运算结果不变,n+1后等于
00000000 00000000 00000000 00100000
即2的5次方=32

因int最大为32,即使最大
01000000 00000000 00000000 00000000
经过
n |= n >>> 1;// 01100000 00000000 00000000 00000000
n |= n >>> 2;// 01111000 00000000 00000000 00000000
n |= n >>> 4;// 01111111 10000000 00000000 00000000
n |= n >>> 8;// 01111111 11111111 10000000 00000000
n |= n >>> 16;// 01111111 11111111 11111111 11111111
最终都会变成全是1的值
其中MAXIMUM_CAPACITY = 1 << 30,即01000000 00000000 00000000 00000000,即最大容量不能超过此值

我们看到通过移位运算hashmap保证了数组的容量一定是2的幂次方,这在之后的hash取模运算和扩容时的高位运算至关重要。

2. 扰动算法获取hashCode值

我们知道hashMap的key必须实现equals()和hashCode()方法,但是hashMap直接使用了此hashCode吗?答案是否

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
比如hashCode值为 
11110111 00101100 10010100 10010010
无符号右移16位后得到
00000000 00000000 10010100 10010010
进行异或运算
0异或任何数是原值,即保持高16位不变,将高16位与低16位进行扰动运算得到低16位

这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,减少hash冲突(hash取模获取索引下标)的概率。

3. 用&(与运算)代替取模运算获取key索引

当我们想要获取hashMap中元素的索引下标时,首先想到的是对数组的长度进行取模运算,这样可以尽量保证元素均匀分布减少冲突。

在put()中进入putVal()方法,我们可以看到这样一段代码

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
        ……

其中i = (n - 1) & hash即元素的索引下标。其中n为数组容量,hash为上面扰动算法获取的hashCode值;

我们知道数组容量n一定是2的幂次方,那n-1即

我们知道数组容量n一定是2的幂次方,如16,即
00000000 00000000 00000000 00010000
那n-1即
00000000 00000000 00000000 00001111
我们知道与运算,0与任何数为0,1与任何数是原值,即会得到原数字的最后四位数字,等同于对16进行取模

借用一下美团的图

image.png

由此我们知道数组容量为2的幂次方在此很重要。

4. 扩容时高位运算均匀分散链表

在resize()方法中对链表进行复制时有此段方法

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

上面代码的作用是将链表进行均匀的分割为两个链表,并一个挂在原索引位置,一个挂在原位置+旧table容量的位置。其中e.hash & oldCap即高位运算,判断高位是0还是1分散链表。

看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例

image.png

我们看到扩容后元素的索引计算,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

这个设计既省去了重新计算hash值的时间,而且新增的高位1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

再次印证了数组容量为2的幂次方的重要性。

总结

本文主要讲解了java位运算的基础知识,以及位运算的应用场景,雪花算法及HashMap中的使用,还有很多高级的框架使用位运算,这里只是抛砖引玉让同学们有个基础认识。

版权声明:本文为博主原创文章,转载请附上原文出处链接

原文链接:juejin.cn/post/707455…

参考文章: blog.csdn.net/javazejian/… tech.meituan.com/2016/06/24/…