hashmap 中hash函数h & (length-1)详解

本文详细解析了HashMap的数据结构、hash算法及resize机制,强调了hash函数的重要性,并介绍了如何通过重写equals和hashcode方法来提高查找效率。
Hashmap是一种非常常用的、应用广泛的数据类型,最近研究到相关的内容,就正好复习一下。网上关于hashmap的文章很多,但到底是自己学习的总结,就发出来跟大家一起分享,一起讨论。 

1、hashmap的数据结构 
要知道hashmap是什么,首先要搞清楚它的数据结构,在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,hashmap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)。
 

 

从图中我们可以看到一个hashmap就是一个数组结构,当新建一个hashmap的时候,就会初始化一个数组。我们来看看java代码: 

Java代码  收藏代码
  1. /** 
  2.      * The table, resized as necessary. Length MUST Always be a power of two. 
  3.      *  FIXME 这里需要注意这句话,至于原因后面会讲到 
  4.      */  
  5.     transient Entry[] table;  

Java代码  收藏代码
  1. static class Entry<K,V> implements Map.Entry<K,V> {  
  2.         final K key;  
  3.         V value;  
  4.         final int hash;  
  5.         Entry<K,V> next;  
  6. ..........  
  7. }  


        上面的Entry就是数组中的元素,它持有一个指向下一个元素的引用,这就构成了链表。 
         当我们往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的,但是理想总是美好的,现实总是有困难需要我们去克服,哈哈~ 

2、hash算法 
我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。 

所以我们首先想到的就是把hashcode对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,能不能找一种更快速,消耗更小的方式那?java中时这样做的,
 

Java代码  收藏代码
  1. static int indexFor(int h, int length) {  
  2.        return h & (length-1);  
  3.    }  


首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,我以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高。 

         看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
 

 


          所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。 
          说到这里,我们再回头看一下hashmap中默认的数组大小是多少,查看源代码可以得知是16,为什么是16,而不是15,也不是20呢,看到上面annegu的解释之后我们就清楚了吧,显然是因为16是2的整数次幂的原因,在小数据量的情况下16比15和20更能减少key之间的碰撞,而加快查询的效率。 

所以,在存储大容量数据的时候,最好预先指定hashmap的size为2的整数次幂次方。就算不指定的话,也会以大于且最接近指定值大小的2次幂来初始化的,代码如下(HashMap的构造方法中):
 
Java代码  收藏代码
  1. // Find a power of 2 >= initialCapacity  
  2.         int capacity = 1;  
  3.         while (capacity < initialCapacity)   
  4.             capacity <<= 1;  




3、hashmap的resize 

       当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 

         那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。 


4、key的hashcode与equals方法改写 
在第一部分hashmap的数据结构中,annegu就写了get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。 

Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。 
在改写equals方法的时候,需要满足以下三点: 
(1) 自反性:就是说a.equals(a)必须为true。 
(2) 对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。 
(3) 传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。 
通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。
 

总结: 
        本文主要描述了HashMap的结构,和hashmap中hash函数的实现,以及该实现的特性,同时描述了hashmap中resize带来性能消耗的根本原因,以及将普通的域模型对象作为key的基本要求。尤其是hash函数的实现,可以说是整个HashMap的精髓所在,只有真正理解了这个hash函数,才可以说对HashMap有了一定的理解。
 


这是hashmap第一篇,主要讲了一下hashmap的数据结构和计算hash的算法。接下去annegu还会写第二篇,主要讲讲LinkedHashMap和LRUHashMap。先做个预告,呵呵~
&lt;think&gt;我们正在讨论Java HashMap中的位运算`(capacity - 1) &amp; hash`。这里的`capacity`是HashMap的当前容量(桶数长度),而`hash`是键(key)的哈希码经过扰动函数处理后的结果。 ### 1. 位运算`&amp;`的作用 这个表达式的目的是计算键值对应该放入哪个桶(即数的哪个索引位置)。在HashMap中,我们通常希望将哈希值映射到数索引范围内,即`[0, capacity-1]`。传统做法是使用取模运算:`hash % capacity`。但是,当容量是2的幂次方时,我们可以用位运算`&amp;`代替取模运算,因为位运算的效率更高。 ### 2. 为什么是`(capacity - 1) &amp; hash`? - **前提条件**:HashMap要求容量(`capacity`)必须是2的幂次方(例如16, 32, 64等)。这样,`capacity`的二进制表示就是最高位为1,其余位为0(例如16的二进制是`10000`)。 - **`capacity-1`的二进制特点**:当容量是2的幂次方时,`capacity-1`的二进制表示就是所有位都是1。例如: - 容量16:二进制`10000`,减1得`01111`(即15,二进制是`1111`)。 - 容量32:二进制`100000`,减1得`011111`(即31,二进制是`11111`)。 - **按位运算**:将哈希值`hash``capacity-1`进行按位运算,相当于保留`hash`值的低位部分,而丢弃高位部分。因为`capacity-1`的低位全是1,高位全是0(例如,当容量为16时,`capacity-1`是15,即`0000...00001111`),所以: ``` hash &amp; (capacity-1) ``` 相当于取`hash`的低若干位(位数等于`capacity-1`的二进制位数),结果一定在`[0, capacity-1]`范围内。 ### 3. 取模运算的等价性 当`capacity`是2的幂次方时,以下等式成立: $$ \text{index} = \text{hash} \mod \text{capacity} = \text{hash} \&amp; (\text{capacity} - 1) $$ 这是因为取模运算在除数是2的幂次方时,可以用位运算替代。 ### 4. 示例 假设容量为16(即`capacity=16`,则`capacity-1=15`,二进制`...00001111`),有两个不同的哈希值: - `hash1 = 5`(二进制`...00000101`): ``` 0000 0000 ... 0000 0101 (5) &amp; 0000 0000 ... 0000 1111 (15) ----------------------------- 0000 0000 ... 0000 0101 (5) &rarr; 索引5 ``` - `hash2 = 21`(二进制`...00010101`): ``` 0000 0000 ... 0001 0101 (21) &amp; 0000 0000 ... 0000 1111 (15) ----------------------------- 0000 0000 ... 0000 0101 (5) &rarr; 索引5 ``` 注意:这里`21 &amp; 15`等于5,因为21除以16的余数就是5(即21 mod 16=5)。 ### 5. 优势 - **高效**:位运算比取模运算快得多,尤其是在高并发场景下。 - **优化**:由于HashMap的容量总是2的幂次方,因此这个优化始终可用。 ### 6. 注意事项 - **哈希冲突**:尽管这种计算索引的方法效率高,但不同的哈希值可能计算出相同的索引(如上面的5和21),这就是哈希冲突。HashMap通过链表和红黑树解决冲突。 - **哈希值的分布**:为了减少冲突,HashMap通过扰动函数(将键的原始哈希码其高16位异或)来增加随机性。 ### 总结 `(capacity - 1) &amp; hash`是HashMap中用于计算键值对存放位置的核心运算,它利用位运算代替取模运算来提高效率,要求容量必须是2的幂次方。这种设计是HashMap高性能的关键之一[^1]。 --- &sect;&sect;相关问题&sect;&sect; 1. 为什么HashMap的容量必须是2的幂次方? 2. 除了`&amp;`运算HashMap还有哪些性能优化措施? 3. 哈希冲突的解决方式除了链表和红黑树,还有其他方法吗? 4. 如何设计一个好的哈希函数来减少冲突? &lt;/think&gt;### Java HashMap 中 `(capacity - 1) &amp; hash` 的含义详解 在 Java HashMap 的实现中,表达式 `(capacity - 1) &amp; hash` 是**确定键值对存储位置的核⼼计算**。它的作用相当于传统哈希表中的取模运算,但通过位运算实现了更⾼效的索引定位。以下是逐步解析: --- ### 一、核心原理 1. **设计目标** 将任意哈希值 `hash` 映射到桶数的有效索引范围内:`[0, capacity-1]` (`capacity` 是 HashMap 桶数的当前长度) 2. **数学等价关系** 当 `capacity` 是 2 的幂次方时,以下等式成立: $$ \text{index} = \text{hash} \mod \text{capacity} = \text{hash} \&amp; (\text{capacity} - 1) $$ ⚠️ **关键前提**:HashMap 强制要求 `capacity` 必须是 2 的幂次方(如 16、32、64) 3. **位运算原理** 设 `capacity = 2^n`,则 `capacity - 1` 的⼆进制表示为 **低位全 1**: ``` capacity = 16 &rarr; 二进制 0b10000 capacity-1 = 15 &rarr; 二进制 0b01111 (低位4位全1) ``` **按位操作**相当于保留 `hash` 的低 $n$ 位: ``` hash &amp; 0b...01111 // 只保留最低4位有效值 ``` --- ### 二、计算示例 假设 `capacity=16`(即 `capacity-1=15`),对比不同哈希值的计算结果: | 哈希值 | 二进制表示 | `&amp; 15` 结果 | 等效取模 | |--------|-------------------|-------------|----------| | 5 | `0b...00101` | `5` | 5%16=5 | | 17 | `0b...10001` | `1` | 17%16=1 | | 31 | `0b...11111` | `15` | 31%16=15 | | 32 | `0b...100000` | `0` | 32%16=0 | &gt; 💡 **本质**:丢弃哈希值的高位,仅保留低位作为索引值 --- ### 三、为什么使用位运算而非取模? 1. **性能优势** 位运算 `&amp;` 的执⾏速度远快于取模运算 `%`(通常快 5-10 倍)[^1],这对 HashMap 这种高频操作的数据结构至关重要。 2. **避免负索引** 当 `hash` 为负数时: ```java hash % capacity; // 可能返回负索引(需额外处理) (capacity-1) &amp; hash; // 结果永为 [0, capacity-1] 的正数 ``` 3. **扩容优化** 扩容时(如 16&rarr;32),只需将旧桶数据按 **新增高位比特** 重新分布: ``` 旧索引: hash &amp; 00001111 &rarr; 4位索引 (0-15) 新索引: hash &amp; 00011111 &rarr; 5位索引 (0-31) &uarr; 新增位 ``` 数据迁移只需判断新增位的值(0 或 1),效率极高[^2]。 --- ### 四、验证源码(Java 17 HashMap 节选) ```java final V putVal(int hash, K key, V value) { Node&lt;K,V&gt;[] tab; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // &darr;&darr;&darr; 关键计算 &darr;&darr;&darr; i = (n - 1) &amp; hash; // 计算桶索引 // ...后续处理冲突 } ``` --- ### 五、注意事项 1. **哈希分布质量依赖** 若哈希函数产生大量低位相同的值(如连续数字),仍会导致哈希冲突。需配合扰动函数优化: ```java // Java 8 扰动函数:将高16位异或到低16位 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h &gt;&gt;&gt; 16); } ``` 2. **容量强制为 2 的幂** HashMap 在初始化/扩容时通过 `tableSizeFor()` 保证: ```java static final int tableSizeFor(int cap) { int n = -1 &gt;&gt;&gt; Integer.numberOfLeadingZeros(cap - 1); return (n &lt; 0) ? 1 : (n &gt;= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } ``` --- ### 总结 `(capacity - 1) &amp; hash` 是 HashMap 实现⾼效索引定位的核⼼技巧: 1. **数学本质**:利⽤位运算替代取模运算 ($\text{index} = \text{hash} \mod 2^n$) 2. **性能优势**:位运算效率远超取模运算 3. **设计约束**:要求容量必须为 2 的幂次方 4. **扩容优化**:支持高效数据迁移 &gt; 此设计是 HashMap ⾼性能的基石之⼀[^3]。 ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值