Java HashMap中在resize()时候的rehash,即再哈希法的理解

本文深入探讨了HashMap的扩容机制,包括何时触发扩容、扩容过程及数据转移细节,并对比了JDK1.7与JDK1.8的区别。

HashMap的扩容机制---resize()

虽然在hashmap的原理里面有这段,但是这个单独拿出来讲rehash或者resize()也是极好的。

什么时候扩容:当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值(知道这个阈字怎么念吗?不念fa值,念yu值四声)---即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。

扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。

先看一下什么时候,resize();

[java]  view plain  copy
  1. /**  
  2.  * HashMap 添加节点  
  3.  *  
  4.  * @param hash        当前key生成的hashcode  
  5.  * @param key         要添加到 HashMap 的key  
  6.  * @param value       要添加到 HashMap 的value  
  7.  * @param bucketIndex 桶,也就是这个要添加 HashMap 里的这个数据对应到数组的位置下标  
  8.  */    
  9. void addEntry(int hash, K key, V value, int bucketIndex) {    
  10.     //size:The number of key-value mappings contained in this map.    
  11.     //threshold:The next size value at which to resize (capacity * load factor)    
  12.     //数组扩容条件:1.已经存在的key-value mappings的个数大于等于阈值    
  13.     //             2.底层数组的bucketIndex坐标处不等于null    
  14.     if ((size >= threshold) && ( != table[bucketIndex])) {    
  15.         resize(2 * table.length);//扩容之后,数组长度变了    
  16.         hash = ( != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢?    
  17.         bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。    
  18.     }    
  19.     createEntry(hash, key, value, bucketIndex);    
  20. }    
  21.     
  22. /**  
  23.  * 这地方就是链表出现的地方,有2种情况  
  24.  * 1,原来的桶bucketIndex处是没值的,那么就不会有链表出来啦  
  25.  * 2,原来这地方有值,那么根据Entry的构造函数,把新传进来的key-value mapping放在数组上,原来的就挂在这个新来的next属性上了  
  26.  */    
  27. void createEntry(int hash, K key, V value, int bucketIndex) {    
  28.     HashMap.Entry<K, V> e = table[bucketIndex];    
  29.     table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e);    
  30.     size++;    
  31. }  

我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。

[java]  view plain  copy
  1. void resize(int newCapacity) {   //传入新的容量  
  2.     Entry[] oldTable = table;    //引用扩容前的Entry数组  
  3.     int oldCapacity = oldTable.length;  
  4.     if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了  
  5.         threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了  
  6.         return;  
  7.     }  
  8.   
  9.     Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组  
  10.     transfer(newTable);                         //!!将数据转移到新的Entry数组里  
  11.     table = newTable;                           //HashMap的table属性引用新的Entry数组  
  12.     threshold = (int) (newCapacity * loadFactor);//修改阈值  
  13. }  
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。

[java]  view plain  copy
  1. void transfer(Entry[] newTable) {  
  2.     Entry[] src = table;                   //src引用了旧的Entry数组  
  3.     int newCapacity = newTable.length;  
  4.     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组  
  5.         Entry<K, V> e = src[j];             //取得旧Entry数组的每个元素  
  6.         if (e != ) {  
  7.             src[j] = ;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)  
  8.             do {  
  9.                 Entry<K, V> next = e.next;  
  10.                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置  
  11.                 e.next = newTable[i]; //标记[1]  
  12.                 newTable[i] = e;      //将元素放在数组上  
  13.                 e = next;             //访问下一个Entry链上的元素  
  14.             } while (e != );  
  15.         }  
  16.     }  
  17. }  

[java]  view plain  copy
  1. static int indexFor(int h, int length) {  
  2.     return h & (length - 1);  
  3. }  
文章中间部分:四、存储实现;详细解释了为什么indexFor方法中要h & (length-1)

newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

从上面的for循环内部开始说起吧:详细解释下,这个转存的过程。和怎么个头插入法.
Entry<K, V> e = src[j];
这句话,就把原来数组上的那个链表的引用就给接手了,所以下面src[j] = null;可以放心大胆的置空,释放空间。告诉gc这个地方可以回收啦。
继续到do while 循环里面,
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity);计算出元素在新数组中的位置
下面就是单链表的头插入方式转存元素啦

关于这个 单链表的头插入方式 的理解,我多说两句。
这地方我再看的时候,就有点蒙了,他到底怎么在插到新的数组里面的?
要是在插入新数组的时候,也出现了一个数组下标的位置处,出现了多个节点的话,那又是怎么插入的呢?
1,假设现在刚刚插入到新数组上,因为是对象数组,数组都是要默认有初始值的,那么这个数组的初始值都是null。不信的可以新建个Javabean数组测试下。
那么e.next = newTable[i],也就是e.next = null啦。然后再newTable[i] = e;也就是 说这个时候,这个数组的这个下标位置的值设置成这个e啦。
2,假设这个时候,继续上面的循环,又取第二个数据e2的时候,恰好他的下标和刚刚上面的那个下标相同啦,那么这个时候,是又要有链表产生啦、
e.next = newTable[i];,假设上面第一次存的叫e1吧,那么现在e.next = newTable[i];也就是e.next = e1;
然后再,newTable[i] = e;,把这个后来的赋值在数组下标为i的位置,当然他们两个的位置是相同的啦。然后注意现在的e,我们叫e2吧。e2.next指向的是刚刚的e1,e1的next是null。
这就解释啦:先放在一个索引上的元素终会被放到Entry链的尾部。这句话。

关于什么时候resize()的说明:
看1.7的源码上说的条件是:
if ((size >= threshold) && (null != table[bucketIndex])) {。。。}
其中
size表示当前hashmap里面已经包含的元素的个数。
threshold:threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
一般是容量值X加载因子。
而1.8的是:
if (++size > threshold){}
其中
size:The number of key-value mappings contained in this map.和上面的是一样的
threshold:newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
也是一样的,
最后总结一下:就是这个map里面包含的元素,也就是size的值,大于等于这个阈值的时候,才会resize();
具体到实际情况就是:假设现在阈值是4;在添加下一个假设是第5个元素的时候,这个时候的size还是原来的,还没加1,size=4,那么阈值也是4的时候,
当执行put方法,添加第5个的时候,这个时候,4 >= 4。元素个数等于阈值。就要resize()啦。添加第4的时候,还是3 >= 4不成立,不需要resize()。
经过这番解释,可以发现下面的这个例子,不应该是在添加第二个的时候resize(),而是在添加第三个的时候,才resize()的。
这个也是我后来再细看的时候,发现的。当然,这个咱可以先忽略,重点看如何resize(),以及如何将旧数据移动到新数组的

下面举个例子说明下扩容过程。

这句话是重点----hash(){return key % table.length;}方法,就是翻译下面的一行解释:

假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。

其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。



下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,

经过rehash之后,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。对应的就是下方的resize的注释。

[java]  view plain  copy
  1. /** 
  2.  * Initializes or doubles table size.  If null, allocates in 
  3.  * accord with initial capacity target held in field threshold. 
  4.  * Otherwise, because we are using power-of-two expansion, the 
  5.  * elements from each bin must either stay at same index, or move 
  6.  * with a power of two offset in the new table. 
  7.  * 
  8.  * @return the table 
  9.  */  
  10. final Node<K,V>[] resize() {  

看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希值(也就是根据key1算出来的hashcode值)与高位与运算的结果。


元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:


因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

jdk1.8 hashMap扩容例图

我是 没看懂他连个图是怎么前后对应的,谁看懂了,交流哈赛。

当时上面这个图,没看懂,是因为,他就没说每个节点的hashcode是啥,他怎么确定是保留在原来的位置,还是说在原来位置的基础上再加个原来数组的长度呢。所以,上面那个图仅仅具有丁点儿参考价值。

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。有兴趣的同学可以研究下JDK1.8的resize源码,写的很赞,如下:

 1 final Node<K,V>[] resize() {
 2     Node<K,V>[] oldTab = table;
 3     int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4     int oldThr = threshold;
 5     int newCap, newThr = 0;
 6     if (oldCap > 0) {
 7         // 超过最大值就不再扩充了,就只好随你碰撞去吧
 8         if (oldCap >= MAXIMUM_CAPACITY) {
 9             threshold = Integer.MAX_VALUE;
10             return oldTab;
11         }
12         // 没超过最大值,就扩充为原来的2倍
13         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
14                  oldCap >= DEFAULT_INITIAL_CAPACITY)
15             newThr = oldThr << 1; // double threshold
16     }
17     else if (oldThr > 0) // initial capacity was placed in threshold
18         newCap = oldThr;
19     else {               // zero initial threshold signifies using defaults
20         newCap = DEFAULT_INITIAL_CAPACITY;
21         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
22     }
23     // 计算新的resize上限
24     if (newThr == 0) {
25 
26         float ft = (float)newCap * loadFactor;
27         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
28                   (int)ft : Integer.MAX_VALUE);
29     }
30     threshold = newThr;
31     @SuppressWarnings({"rawtypes","unchecked"})
32         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
33     table = newTab;
34     if (oldTab != null) {
35         // 把每个bucket都移动到新的buckets中
36         for (int j = 0; j < oldCap; ++j) {
37             Node<K,V> e;
38             if ((e = oldTab[j]) != null) {
39                 oldTab[j] = null;
40                 if (e.next == null)
41                     newTab[e.hash & (newCap - 1)] = e;
42                 else if (e instanceof TreeNode)
43                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
44                 else { // 链表优化重hash的代码块
45                     Node<K,V> loHead = null, loTail = null;
46                     Node<K,V> hiHead = null, hiTail = null;
47                     Node<K,V> next;
48                     do {
49                         next = e.next;
50                         // 原索引
51                         if ((e.hash & oldCap) == 0) {
52                             if (loTail == null)
53                                 loHead = e;
54                             else
55                                 loTail.next = e;
56                             loTail = e;
57                         }
58                         // 原索引+oldCap
59                         else {
60                             if (hiTail == null)
61                                 hiHead = e;
62                             else
63                                 hiTail.next = e;
64                             hiTail = e;
65                         }
66                     } while ((e = next) != null);
67                     // 原索引放到bucket里
68                     if (loTail != null) {
69                         loTail.next = null;
70                         newTab[j] = loHead;
71                     }
72                     // 原索引+oldCap放到bucket里
73                     if (hiTail != null) {
74                         hiTail.next = null;
75                         newTab[j + oldCap] = hiHead;
76                     }
77                 }
78             }
79         }
80     }
81     return newTab;
82 }
 
<think>我们正在讨论Java中哈希法的实现和使用。根据引用内容,我们可以知道Java中哈希表的基本实现、如何使用哈希表进行查找,以及一些实际应用示例。下面我将从几个方面来组织回答:哈希表的基本概念、Java中的实现、使用方法以及性能优化等。 ### 1. 哈希表的基本概念 哈希表(Hash Table)是一种通过哈希函数将键(Key)映射到存储位置的数据结构,从而实现快速查找。核心组件包括: - **哈希函数**:将键映射到哈希值的函数,理想情况下应均匀分布以减少冲突。 - **冲突解决**:当不同键映射到同一位置时,需要有解决冲突的方法,常见的有链地址法和开放寻址法。 ### 2. Java中的哈希表实现 Java提供了多种哈希表实现,主要位于`java.util`包中,包括: - **HashMap**:最常用的哈希表实现,基于键值对存储,允许`null`键和`null`值,非线程安全。 - **Hashtable**:线程安全的哈希表,但性能较低,不推荐使用(可用`ConcurrentHashMap`替代)。 - **LinkedHashMap**:继承自`HashMap`,通过双向链表维护插入顺序或访问顺序。 - **ConcurrentHashMap**:支持高并发的线程安全哈希表,采用分段锁技术。 #### 示例:创建和使用HashMap ```java import java.util.HashMap; public class HashMapExample { public static void main(String[] args) { // 创建HashMap实例 HashMap<String, Integer> map = new HashMap<>(); // 插入键值对 map.put("apple", 10); map.put("banana", 20); map.put("orange", 30); // 通过键获取值 int appleCount = map.get("apple"); // 返回10 System.out.println("苹果数量: " + appleCount); // 检查键是否存在 if (map.containsKey("banana")) { System.out.println("香蕉存在"); } // 遍历所有键值对 map.forEach((key, value) -> System.out.println(key + ": " + value) ); } } ``` ### 3. 哈希冲突解决方法 Java的`HashMap`采用**链地址法**解决冲突(Java 8后当链表长度超过阈值时转换为红黑树): - **链地址法**:每个桶(bucket)是一个链表(或树),冲突的元素被添加到同一桶的链表中。 - **开放寻址法**:Java的`ThreadLocalMap`使用此方法,但`HashMap`未采用。 #### 冲突解决对性能的影响 - 理想情况下,哈希函数均匀分布,查找时间复杂度为$O(1)$。 - 最坏情况下(所有键冲突),链表退化为$O(n)$,树化后提升为$O(\log n)$。 ### 4. 自定义哈希函数 在Java中,自定义对象作为键时需重写`hashCode()`和`equals()`方法: - `hashCode()`:计算对象的哈希码,应满足一致性(同一对象多次调用结果相同)和均匀分布。 - `equals()`:用于比较两个对象是否相等。 #### 示例:自定义键类型 ```java class Student { private String id; private String name; public Student(String id, String name) { this.id = id; this.name = name; } @Override public int hashCode() { // 使用Objects类辅助生成哈希码 return java.util.Objects.hash(id, name); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Student other = (Student) obj; return Objects.equals(id, other.id) && Objects.equals(name, other.name); } } // 使用示例 HashMap<Student, Integer> studentScores = new HashMap<>(); studentScores.put(new Student("001", "Alice"), 90); int score = studentScores.get(new Student("001", "Alice")); // 返回90 ``` ### 5. 性能调优 - **初始容量和负载因子**:创建`HashMap`时可指定初始容量和负载因子(默认0.75)。当元素数量超过`容量*负载因子`时,自动扩容并重新哈希。 ```java // 初始容量16,负载因子0.75 HashMap<String, Integer> map = new HashMap<>(16, 0.75f); ``` - **避免频繁扩容**:预估元素数量,设置合适的初始容量。 - **选择高效哈希函数**:减少冲突,提升分布均匀性。 ### 6. 高级应用:一致性哈希 在分布式系统中,一致性哈希用于解决数据分片和负载均衡问题。Java中可通过第三方库(如Google Guava)实现: ```java import com.google.common.hash.Hashing; import java.nio.charset.StandardCharsets; // 计算一致性哈希值 int hash = Hashing.consistentHash( Hashing.sha256().hashString("key", StandardCharsets.UTF_8), numberOfNodes ); ``` ### 总结 Java中哈希法的实现主要依赖于`HashMap`等集合类,使用时需注意: 1. 正确重写`hashCode()`和`equals()`方法 2. 合理配置初始容量和负载因子 3. 理解冲突解决机制及其对性能的影响 通过优化哈希函数和参数设置,可显著提升哈希表性能[^1][^2][^3][^4]。 --- §§相关问题§§ 1. JavaHashMap的扩容机制具体是如何工作的? 2. 如何设计一个高效的哈希函数来减少冲突? 3. 在并发环境下,ConcurrentHashMap是如何实现线程安全的? 4. 一致性哈希在分布式系统中有哪些具体应用场景?</think>### Java 哈希法实现与使用详解 #### 一、核心实现原理 1. **哈希函数设计** - Java 使用 `hashCode()` 方法生成初始哈希值 - 默认实现基于对象内存地址,可自定义重写 - 优化公式:$index = (h \oplus (h >>> 16)) \& (capacity - 1)$ (高位参与运算,减少碰撞概率) 2. **冲突解决机制** ```mermaid graph TD A[发生哈希冲突] --> B[Java 8+] A --> C[Java 7-] B --> D[链表长度<8?] D -->|是| E[链表存储] D -->|否| F[转红黑树存储] C --> G[纯链表结构] ``` #### 二、标准库实现类 | **类名** | **特点** | **线程安全** | **适用场景** | |------------------|----------------------------------|-------------|--------------------------| | `HashMap` | 数组+链表/红黑树,负载因子0.75 | ❌ | 通用键值存储 | | `Hashtable` | 全方法同步,性能较低 | ✔️ | 遗留系统兼容 | | `ConcurrentHashMap` | 分段锁技术,高并发优化 | ✔️ | 高并发场景[^2] | | `LinkedHashMap` | 维护插入顺序双向链表 | ❌ | 需要顺序访问的场景 | #### 三、基础操作示例 ```java import java.util.HashMap; public class HashExample { public static void main(String[] args) { // 1. 创建哈希表 HashMap<String, Integer> inventory = new HashMap<>(16); // 初始容量16 // 2. 插入数据 inventory.put("apple", 50); inventory.put("banana", 30); inventory.putIfAbsent("orange", 40); // 不存在时插入 // 3. 访问数据 int appleCount = inventory.get("apple"); // 返回50 Integer unknown = inventory.get("pear"); // 返回null // 4. 更新数据 inventory.compute("apple", (k, v) -> v + 10); // apple=60 inventory.merge("banana", 5, Integer::sum); // banana=35 // 5. 删除数据 inventory.remove("orange"); // 6. 遍历方式 inventory.forEach((k, v) -> System.out.println(k + ": " + v) ); } } ``` #### 四、性能优化要点 1. **初始容量设置** - 预估元素数量$N$,初始容量 = $N / 0.75 + 1$ - 避免多次扩容:`new HashMap<>(1000)` 优于默认16 2. **负载因子调整** - 默认0.75(空间/时间平衡点) - 高频查询场景:降低至0.5(减少冲突) - 内存敏感场景:提高至0.9(减少空间) 3. **自定义对象作为键** - **必须重写** `hashCode()` 和 `equals()` 方法 - 示例实现: ```java class Product { private String id; private String name; @Override public int hashCode() { return Objects.hash(id, name); // 多字段联合哈希 } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Product)) return false; Product p = (Product) o; return id.equals(p.id) && name.equals(p.name); } } ``` #### 五、高级应用场景 1. **缓存实现** ```java import java.util.LinkedHashMap; import java.util.Map; class LRUCache<K, V> extends LinkedHashMap<K, V> { private final int maxSize; public LRUCache(int maxSize) { super(maxSize, 0.75f, true); // 访问顺序排序 this.maxSize = maxSize; } @Override protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return size() > maxSize; // 自动移除最旧条目 } } ``` 2. **数据去重** ```java Set<String> uniqueWords = new HashSet<>(); words.stream().filter(uniqueWords::add).count(); // 高效去重 ``` 3. **一致性哈希(分布式系统)** ```java import com.google.common.hash.Hashing; int nodeIndex = Hashing.consistentHash( Hashing.sha256().hashString(key, UTF_8), nodeCount ); ``` #### 六、最佳实践 1. **线程安全选择** - 优先用 `ConcurrentHashMap` 替代 `Collections.synchronizedMap()` - 分段锁提升并发效率 2. **避免哈希碰撞攻击** - 使用 `String` 作键时启用防护: `-Djdk.map.althashing.threshold=512` 3. **监控与调优** - 使用JMX监控负载因子:`java.util.Collections$SynchronizedMap` - 调整JVM参数:`-XX:+PrintHeapAtGC` 分析内存占用 > **关键原则**:哈希表在最优情况下提供$O(1)$时间复杂度的查找/插入,但需避免哈希退化(所有元素进入同一桶)导致性能降至$O(n)$或$O(\log n)$[^1][^3]。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值