HashMap为什么总是使用2的幂作为哈希表的大小

导读

今天在看HashMap源码时,发现HashMap扩容时总是2的幂次,也就是是说使用2的幂作为哈希表的大小,那究竟是为什么呢?今天我们就来讨论这个问题

HashMap

HashMap 默认的初始化⼤⼩为16。之后每次扩充,容量变为原来的2倍。创建时如果给定了容量初始值,那么 Hashtable 会直接使⽤你给定的⼤⼩,而 HashMap 会将其扩充为2的幂次⽅⼤⼩

源码分析

这里以put为例

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
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);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

这里我们注意:
在这里插入图片描述

  • (n - 1) & hash实际上是计算出 key 在 tab 中索引位置,当key的hash没有冲突时,key在HashMap存储的位置就是匹配的node中的第一个节点。如果hash有冲突,就会在node里面节点中查询,直至匹配到相等的key。

  • 因为 n 永远是2的次幂,所以 n-1 通过 二进制表示,永远都是尾端以连续1的形式表****示(00001111,00000011),当(n - 1) 和 hash 做与运算时,会保留hash中 后 x 位的 1,这样碰撞的几率小

    例如 00001111 & 10000011 = 00000011

这样做有几个好处

  • &运算速度快,至少比%取模运算块
  • 能保证索引值肯定在 capacity 中,不会超出数组长度
  • (n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n

但是这时候就会有一个疑问?为什么要通过 (n - 1) & hash 决定桶的索引呢?

  • 1)key具体应该在哪个桶中,肯定要和key挂钩的,HashMap顾名思义就是通过hash算法高效的把存储的数据查询出来,所以HashMap的所有get 和 set 的操作都和hash相关。

  • 2)既然是通过hash的方式,那么不可避免的会出现hash冲突的场景。hash冲突就是指 2个key 通过hash算法得出的哈希值是相等的。hash冲突是不可避免的,所以如何尽量避免hash冲突,或者在hash冲突时如何高效定位到数据的真实存储位置就是HashMap中最核心的部分。

  • 3)首先要提的一点是 HashMap 中 capacity 可以在构造函数中指定,如果不指定默认是2 的 (n = 4) 次方,即16。

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
  • 4)HashMap中的hash也做了比较特别的处理,(h = key.hashCode()) ^ (h >>> 16)。先获得key的hashCode的值 h,然后 h 和 h右移16位做异或运算。实质上是把一个数的低16位与他的高16位做异或运算,因为在前面 (n - 1) & hash 的计算中,hash变量只有末x位会参与到运算。使高16位也参与到hash的运算能减少冲突。

capacity 永远都是 2 次幂,那么如果我们指定 initialCapacity 不为 2次幂时呢,是不是就破坏了这个规则?前文已经说了,HashMap 的tableSizeFor方法做了处理,能保证n永远都是2次幂。

/**
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    //cap-1后,n的二进制最右一位肯定和cap的最右一位不同,即一个为0,一个为1,例如cap=17(00010001),n=cap-1=16(00010000)
    int n = cap - 1;
    //n = (00010000 | 00001000) = 00011000
    n |= n >>> 1;
    //n = (00011000 | 00000110) = 00011110
    n |= n >>> 2;
    //n = (00011110 | 00000001) = 00011111
    n |= n >>> 4;
    //n = (00011111 | 00000000) = 00011111
    n |= n >>> 8;
    //n = (00011111 | 00000000) = 00011111
    n |= n >>> 16;
    //n = 00011111 = 31
    //n = 31 + 1 = 32, 即最终的cap = 32 = 2 的 (n=5)次方
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: HashMap的长度为2的次方是为了提高HashMap的性能。这是因为HashMap的内部实现是通过哈希表来存储数据的,而哈希表大小必须是2的次方。这样可以保证在计算哈希值时,可以通过位运算来代替取模运算,从而提高计算效率。另外,2的次方的长度也可以保证在扩容时,只需要将原来的数组长度翻倍即可,也可以避免出现哈希冲突的情况。 ### 回答2: 在散列表的设计中,hash值会映射到数组中的某个下标上,而为了简化hash值和数组下标之间的映射关系,一般会使用取模操作(即hash值对数组长度取模),来得到合法的数组下标。所以如果数组长度为length,那么下标的范围就是[0,length-1]。 假设数组长度为length,那么对于一个任意的hash值,使用取模操作时会得到一个介于[0,length-1]的整数,也就是说数组下标总是从0开始,以步长为1递增,直到最大值(length-1),这个过程就被称为线性探测。如果数组长度不容易被2的次整除,那么在使用取模操作时,得到的下标的分布会比较规则,导致很多下标都有相同的低位数,因此容易形成一些连续的同余值,这就会导致hash表冲突的严重性加剧,而在解决hash冲突的基于开发式地址探测中,拓展路径的方式不太好,查询时间复杂度和空间浪费都会变得很糟糕。而当数组长度为2的次方时,很容易使用位运算操作来取模,只需要hash值&(length-1)即可得到合法的下标,这个操作等价于对hash值取length的模,只是更快、更简单。而且2的次方有个很好的性质就是二进制表示中只有一个1,所以对于大部分的hash函数,hash值的分布比较均匀,这也就能够尽量减小同余值的影响,从而提高hash表的效率,进而提高查询速度。 因此,为了使散列表的查找性能平衡,从而保证其查询、插入、删除操作的效率,一般情况下我们设计的哈希表的长度都应当是2的次方。 ### 回答3: HashMap是Java中最常用的集合之一,它是通过哈希表来进行实现的。哈希表的底层实际上是一个数组,不论我们向HashMap中添加多少个元素,底层的数组的大小始终都是2的次方。为什么要设计成这样呢?下面就从哈希表的存储结构来解释一下。 哈希表的实现机制是通过哈希函数将元素进行映射,然后直接存放到数组中得到其位置。为了使得哈希表中的元素能够均匀地分布在数组中,我们需要使用到数组对应下标的散列值来对其进行取模运算。当数组长度为2的次方时,对其取模时将会被优化为“与运算”,效率会更高。 而且,当数组长度为2的次方时,可以使得数组扩容时的元素重新映射的下标只能为原下标加上数组的长度。不过,如果数组长度不能为2的次方,则多出的取模运算的时间开销就比较大,而且在扩容时重新计算元素的哈希值也会变得很麻烦。 除非我们某些特定的场景下只有很少的元素需要存储,如需要存储的元素只有10个,那么设置一个长度为11的数组或31的数组都不错。这时候的数组长度等于下一个质数可能是提高容量利用率的一种方式。可以通过使用方法zeroone(用0和1代替模运算,这种方式同样适用于hash set)来避开使用除法和余数符号运算。但在通用场景中,还是建议选择2的次方来作为HashMap的长度。 综上所述,选择2的次方作为HashMap的长度,可以提高哈希表的性能,使得元素能够均匀地分布在数组中,同时还能更加高效地进行扩容。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值