Java-HashMap中的扰动函数、初始化容量、负载因子以及扩容链表拆分

1.扰动函数

在hashmap中,put操作是这样进行的:

public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }

其中会涉及到hash(key)的运算,hash并不是直接使用hashCode(),而是这样:

static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

这里的操作就称之为扰动函数

根据取模操作中的&和(length-1)这篇文章中提到的,计算出hash后我们可以使用&进行取模操作来确定放置在哪里。

现在问题是:计算hash时,为什么不直接用hashCode(),而是

// 把哈希值右移16位,之后与原哈希值做异或运算。 (h = key.hashCode()) ^ (h >>> 16)

先说结论:

增大随机性,优化散列效果,让数据元素更加均匀的散列(减少碰撞)。

1.1 实例

1.1.1 Hash的取值范围

在Java中,hashCode() 方法返回的哈希值是一个32位的有符号整数(int类型)。

因此,哈希值的取值范围是从 [-2147483648, 2147483647](即 -2^312^31-1)。

1.1.2 扰动函数的计算过程

对于扰动hash的函数的计算,做一个拆分。

/** * 扰动函数计算hash * * @param key . * @return hash */ final int hashAndLog(Object key) { int hashCode; if (key == null) { return 0; } hashCode = key.hashCode(); int rightShift = hashCode >>> 16; int result = hashCode ^ rightShift; log.info("int: {},[sourceHash] hashCode : {}", String.format("%-12s", hashCode), to2$Padding(hashCode, 32)); log.info("int: {},[rightShift] hashCode >>> 16 : {}", String.format("%-12s", rightShift), to2$Padding(rightShift, 32)); log.info("int: {},[result] hashCode ^ rightShift: {}", String.format("%-12s", result), to2$Padding(result, 32)); return result; } /** * int值转为2进制显示 * * @param number num * @param padding 填充0 总长度 * @return Binary */ public static String to2$Padding(int number, int padding) { String binaryString = Integer.toBinaryString(number); return String.format("%" + padding + "s", binaryString).replace(' ', '0'); }

测试用例

@Test void name4() { String str = "abc"; int i = hashAndLog(str); }

输出结果

int: 96354 ,[sourceHash] hashCode : 00000000000000010111100001100010 int: 1 ,[rightShift] hashCode >>> 16 : 00000000000000000000000000000001 int: 96355 ,[result] hashCode ^ rightShift: 00000000000000010111100001100011

上面的过程可以用下面的图来表示

image-20230627002302427


1.2 总结

1.异或的特性是什么?

当使用异或运算符(^)对两个二进制数进行操作时,有以下基本规则:

  • 相同为0:如果两个操作数的对应位相同,则结果为0。 例如:0 ^ 0 = 0,1 ^ 1 = 0

  • 不同为1:如果两个操作数的对应位不同,则结果为1。 例如:0 ^ 1 = 1,1 ^ 0 = 1

2.通过将哈希值向右移动16位(h >>> 16),将原哈希值的高位移动到了低位。

将原来的高16位移到低16位后,高16位被0补位,全是0。这个时候,原来的高16位:

  • 如果是0:0 ^ 0 = 0,则还是0
  • 如果是1:1 ^ 0 = 1,则还是1

可以发现,高16位的结果并不会受影响。

3.高16位变成低16位后,与原来的低16位做异或运算。

md这里没想清楚,等我再悟一悟。


下面是个实际的例子。

  • 10万个不重复的单词
  • 128个格子,相当于128长度的数组。

现在就是说这10万个数据怎么放进去这128的数组里。

bugstack.cn 未使用扰动函数

bugstack.cn 使用扰动函数

y值就是每个位置的数据量。

  • 没有使用前,有的位置数据多,有的位置数据少,分布的不够均匀。

  • 使用了扰动函数后,每个位置的数据量均衡许多。

2.初始化容量和负载因子

2.1 初始化容量

Java-取模操作中的&和(length-1)这篇文章中提到过,为了方便快速取模,我们通常将数组大小设置为2n

现在有一个问题,如果在初始化HashMap时,我们将初始化容量设置为17会怎么样?

@Test void name5() { HashMap<String,Object> hashMap = new HashMap<>(17); }

点进去这个构造方法。

public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } // 默认的负载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f;

可以看到调用 this(initialCapacity, DEFAULT_LOAD_FACTOR)传递到另一个构造方法。

public HashMap(int initialCapacity, float loadFactor) { // 初始化容量小于0,抛异常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 初始化容量最大值 if (initialCapacity > MAXIMUM_CAPACITY) // static final int MAXIMUM_CAPACITY = 1 << 30; initialCapacity = MAXIMUM_CAPACITY; // 负载因子 小于0或者不是个数字,抛异常 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 负载因子直接赋值 this.loadFactor = loadFactor; // 阈值需要计算,比如刚刚这里传了个初始化容量17进来,tableSizeFor(17) this.threshold = tableSizeFor(initialCapacity); }
  • loadFactor:负载因子
  • tableSizeFor:阈值

tableSizeFor(initialCapacity)用来计算阈值。

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; }

上面的注释里也能看到,最大容量是这个。

1 左移 30 位的结果是 1073741824。换句话说,MAXIMUM_CAPACITY 的值被设置为 1073741824。

static final int MAXIMUM_CAPACITY = 1 << 30;

容量为啥不是2^32次方?

HashMap为了保持高效性和性能,限制了容量必须是2的幂次。

而2^32是一个超过Java中int类型表示范围的数值(231-1是int类型的最大值),所以HashMap的最大容量被限制为1 << 30,即2的30次方。

这里再补充一句,还记不记得Java中hashCode的取值范围?

在Java中,hashCode() 方法返回的哈希码(哈希值)是一个32位的整数,其取值范围是从[ -231,231-1],即[-2147483648,2147483647]。

废话完了,现在来解释下tableSizeFor这个函数,这个函数是为了找到最邻近输入值的那个最小2n

看懂这个函数,最主要就是搞清楚|=是个啥玩意?

|= 是位运算中的按位或赋值操作符。它将右操作数的位值与左操作数的位值进行按位或运算,并将结果赋值给左操作数。

具体来说,n |= n >>> x 的含义是将 n 的高 x 位的值复制到低 x 位上,实现了一种类似于向上取整的操作。

n |= n >>> 1 表示将 n 的高一半位的值复制到低一半位上,相当于将 n 的最高位复制到次高位上。

n |= n >>> 2 表示将 n 的高两半位的值复制到低两半位上,相当于将 n 的最高两位复制到次高两位上。

以此类推,n |= n >>> 4n |= n >>> 8n |= n >>> 16 会将 n 的高半部分的值复制到低半部分的对应位置上。

以上面的17为例。

bugstack.cn 计算阈值

第一次就是把头一个1复制到第二个1,第二次就是把头两个1复制到次两个1...

有点感觉了对不,那为啥开始计算前,也就是最开始要减去1呢?

考虑到HashMap的容量必须是2的幂次,减去1可以确保在计算过程中不会出现直接得到小于 cap 的2的幂次值的情况。

int n = cap - 1;

假设进来直接是2n,没有做减法,我们写个函数验证下。

static final int tableSizeFor(int cap) { int MAXIMUM_CAPACITY = 1 << 30; int n = cap; 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; } @Test void name5() { int i = tableSizeFor(16); log.info("size: {}", i); }

输出结果

size: 32

哦豁,变32了,浪费了嘛,所以,要排除掉本身就是2n的情况。

2.2 负载因子

要选择一个合理的大小下进行扩容,默认值0.75,就是说。

当阈值容量占了3/4时赶紧扩容,减少Hash碰撞。

同时0.75是一个默认构造值,在创建HashMap也可以调整,如果你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

是说数组(绿色)位置的元素数量超过loadFactor,还是所有的(绿色+紫色)元素数量超过loadFactor?

image-20230628231458194

先说结论,是所有的元素,后文分析。

3.扩容元素拆分


__EOF__

本文作者羊37
本文链接https://www.cnblogs.com/yang37/p/17507527.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   羊37  阅读(560)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示