【整理】HashMap的原理及遍历

本文详细介绍了HashMap的三种遍历方法:entrySet、keySet和用迭代器遍历。强调了在遍历HashMap时避免修改键值对的重要性,以及entrySet相比keySet的效率优势。文章还探讨了HashMap的存取机制,包括初始容量、负载因子、扩容策略和为何key空间长度设为2的幂。此外,解释了良好的hashCode实现对性能的影响,以及HashMap的Fail-Fast机制。最后,提到了LinkedHashMap和WeakHashMap的特点,前者用于有序遍历和实现LRU缓存,后者通过弱引用避免内存泄漏。

【遍历方法一】

【遍历方法二:用keySet遍历】

 

【遍历方法三:用entrySet遍历】

      使用HashMap的匿名内部类Entry遍历比使用keySet()效率要高很多,使用forEach循环时要注意不要在循环的过程中改变键值对的任何一方的值,否则出现哈希表的值没有随着键值的改变而改变,到时候在删除的时候会出现问题。

      此外,entrySet比keySet快些。对于keySet其实是遍历了2次,一次是转为iterator,一次就从hashmap中取出key所对于的value。而entrySet只是遍历了第一次,他把key和value都放到了entry中,所以就快了。

 

【存取之美 —— HashMap原理、源码、实践】

      HashMap是一种十分常用的数据结构,作为一个应用开发人员,对其原理、实现的加深理解有助于更高效地进行数据存取。本文所用的jdk版本为1.5。
【使用HashMap】
     《Effective JAVA》中认为,99%的情况下,当你覆盖了equals方法后,请务必覆盖hashCode方法。默认情况下,这两者会采用Object的“原生”实现方式,即:

Java代码
  1. protected native int hashCode();   
  2. public boolean equals(Object obj) {   
  3.     return (this == obj);   
  4. }  
     

      hashCode方法的定义用到了native关键字,表示它是由C或C++采用较为底层的方式来实现的,你可以认为它返回了该对象的内存地址;而缺省equals则认为,只有当两者引用同一个对象时,才认为它们是相等的。如果你只是覆盖了equals()而没有重新定义hashCode(),在读取HashMap的时候,除非你使用一个与你保存时引用完全相同的对象作为key值,否则你将得不到该key所对应的值。

另一方面,你应该尽量避免使用“可变”的类作为HashMap的键。如果你将一个对象作为键值并保存在HashMap中,之后又改变了其状态,那么HashMap就会产生混乱,你所保存的值可能丢失(尽管遍历集合可能可以找到)。可参考http://www.ibm.com/developerworks/cn/java/j-jtp02183/

【HashMap存取机制】
      Hashmap实际上是一个数组和链表的结合体,利用数组来模拟一个个桶(类似于Bucket Sort)以快速存取不同hashCode的key,对于相同hashCode的不同key,再调用其equals方法从List中提取出和key所相对应的value。
      JAVA中hashMap的初始化主要是为initialCapacity和loadFactor这两个属性赋值。前者表示hashMap中用来区分不同hash值的key空间长度,后者是指定了当hashMap中的元素超过多少的时候,开始自动扩容,。默认情况下initialCapacity为16,loadFactor为0.75,它表示一开始hashMap可以存放16个不同的hashCode,当填充到第12个的时候,hashMap会自动将其key空间的长度扩容到32,以此类推;这点可以从源码中看出来:

Java代码
  1. void addEntry(int hash, K key, V value, int bucketIndex) {   
  2.     Entry<K,V> e = table[bucketIndex];   
  3.         table[bucketIndex] = new Entry<K,V>(hash, key, value, e);   
  4.         if (size++ >= threshold)   
  5.             resize(2 * table.length);   
  6. }  
 

      而每当hashMap扩容后,内部的每个元素存放的位置都会发生变化(因为元素的最终位置是其hashCode对key空间长度取模而得),因此resize方法中又会调用transfer函数,用来重新分配内部的元素;这个过程成为rehash,是十分消耗性能的,因此在可预知元素的个数的情况下,一般应该避免使用缺省的initialCapacity,而是通过构造函数为其指定一个值。例如我们可能会想要将数据库查询所得1000条记录以某个特定字段(比如ID)为key缓存在hashMap中,为了提高效率、避免rehash,可以直接指定initialCapacity为2048。
     另一个值得注意的地方是,hashMap其key空间的长度一定为2的N次方,这一点可以从一下源码中看出来:

Java代码
  1. int capacity = 1;   
  2. while (capacity < initialCapacity)    
  3.     capacity <<= 1;  
 

    即使我们在构造函数中指定的initialCapacity不是2的平方数,capacity还是会被赋值为2的N次方。
    为什么Sun Microsystem的工程师要将hashMap key空间的长度设为2的N次方呢?这里参考R.W.Floyed给出的衡量散列思想的三个标准:
    一个好的hash算法的计算应该是非常快的
    一个好的hash算法应该是冲突极小化
    如果存在冲突,应该是冲突均匀化
    为了将各元素的hashCode保存至长度为Length的key数组中,一般采用取模的方式,即index = hashCode % Length。不可避免的,存在多个不同对象的hashCode被安排在同一位置,这就是我们平时所谓的“冲突”。如果仅仅是考虑元素均匀化与冲突极小化,似乎应该将Length取为素数(尽管没有明显的理论来支持这一点,但数学家们通过大量的实践得出结论,对素数取模的产生结果的无关性要大于其它数字)。为此,Craig Larman and Rhett Guthrie《Java Performence》中对此也大加抨击。为了弄清楚这个问题,Bruce Eckel(Thinking in JAVA的作者)专程采访了java.util.hashMap的作者Joshua Bloch,并将他采用这种设计的原因放到了网上(http://www.roseindia.net/javatutorials/javahashmap.shtml) 。 
     上述设计的原因在于,取模运算在包括JAVA在内的大多数语言中的效率都十分低下,而当除数为2的N次方时,取模运算将退化为最简单的位运算,其效率明显提升(按照Bruce Eckel给出的数据,大约可以提升5~8倍) 。看看JDK中是如何实现的:

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

      当key空间长度为2的N次方时,计算hashCode为h的元素的索引可以用简单的与操作来代替笨拙的取模操作!假设某个对象的hashCode为35(二进制为100011),而hashMap采用默认的initialCapacity(16),那么indexFor计算所得结果将会是100011 & 1111 = 11,即十进制的3,是不是恰好是35 Mod 16。
     上面的方法有一个问题,就是它的计算结果仅有对象hashCode的低位决定,而高位被统统屏蔽了;以上面为例,19(10011)、35(100011)、67(1000011)等就具有相同的结果。针对这个问题, Joshua Bloch采用了“防御性编程”的解决方法,在使用各对象的hashCode之前对其进行二次Hash,参看JDK中的源码:

Java代码
  1. static int hash(Object x) {   
  2.         int h = x.hashCode();   
  3.         h += ~(h << 9);   
  4.         h ^=  (h >>> 14);   
  5.         h +=  (h << 4);   
  6.         h ^=  (h >>> 10);   
  7.         return h;   
  8.     }  
 

      采用这种旋转Hash函数的主要目的是让原有hashCode的高位信息也能被充分利用,且兼顾计算效率以及数据统计的特性,其具体的原理已超出了本文的领域。
      加快Hash效率的另一个有效途径是编写良好的自定义对象的HashCode,String的实现采用了如下的计算方法:

Java代码
  1. for (int i = 0; i < len; i++) {   
  2. h = 31*h + val[off++];   
  3. }   
  4. hash = h;  
 

      这种方法HashCode的计算方法可能最早出现在Brian W. Kernighan和Dennis M. Ritchie的《The C Programming Language》中,被认为是性价比最高的算法(又被称为times33算法,因为C中乘数常量为33,JAVA中改为31),实际上,包括List在内的大多数的对象都是用这种方法计算Hash值。
      另一种比较特殊的hash算法称为布隆过滤器,它以牺牲细微精度为代价,换来存储空间的大量节俭,常用于诸如判断用户名重复、是否在黑名单上等等,可以参考李开复的数学之美系列第13篇(http://googlechinablog.com/2006/08/blog-post.html
【Fail-Fast机制】
      众所周知,HashMap不是线程安全的集合类。但在某些容错能力较好的应用中,如果你不想仅仅因为1%的可能性而去承受hashTable的同步开销,则可以考虑利用一下HashMap的Fail-Fast机制,其具体实现如下:

Java代码
  1. Entry<K,V> nextEntry() {    
  2. if (modCount != expectedModCount)   
  3.     throw new ConcurrentModificationException();   
  4.                      ……   
  5. }  
 

      其中modCount为HashMap的一个实例变量,并且被声明为volatile,表示任何线程都可以看到该变量被其它线程修改的结果(根据JVM内存模型的优化,每一个线程都会存一份自己的工作内存,此工作内存的内容与本地内存并非时时刻刻都同步,因此可能会出现线程间的修改不可见的问题) 。使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程中,通过每次比较两者是否相等来判断HashMap是否在内部或被其它线程修改。HashMap的大多数修改方法都会改变ModCount,参考下面的源码:

Java代码
  1. public V put(K key, V value) {   
  2.     K k = maskNull(key);   
  3.         int hash = hash(k);   
  4.         int i = indexFor(hash, table.length);   
  5.         for (Entry<K,V> e = table[i]; e != null; e = e.next) {   
  6.             if (e.hash == hash && eq(k, e.key)) {   
  7.                 V oldValue = e.value;   
  8.                 e.value = value;   
  9.                 e.recordAccess(this);   
  10.                 return oldValue;   
  11.             }   
  12.         }   
  13.         modCount++;   
  14.         addEntry(hash, k, value, i);   
  15.         return null;   
  16.     }  

      以put方法为例,每次往HashMap中添加元素都会导致modCount自增。其它诸如remove、clear方法也都包含类似的操作。
从上面可以看出,HashMap所采用的Fail-Fast机制本质上是一种乐观锁机制,通过检查状态——没有问题则忽略——有问题则抛出异常的方式,来避免线程同步的开销,下面给出一个在单线程环境下发生Fast-Fail的例子:

Java代码
  1. class Test {     
  2.     public static void main(String[] args) {                
  3.         java.util.HashMap<Object,String> map=new java.util.HashMap<Object,String>();     
  4.        map.put(new Object(), "a");     
  5.        map.put(new Object(), "b");     
  6.        java.util.Iterator<Object> it=map.keySet().iterator();     
  7.        while(it.hasNext()){     
  8.            it.next();     
  9.            map.put("""");          
  10.         System.out.println(map.size());     
  11.     }     
  12. }  
 

      运行上面的代码会抛出java.util.ConcurrentModificationException,因为在迭代过程中修改了HashMap内部的元素导致modCount自增。若将上面代码中 map.put(new Object(), "b") 这句注释掉,程序会顺利通过,因为此时HashMap中只包含一个元素,经过一次迭代后已到了尾部,所以不会出现问题,也就没有抛出异常的必要了。
在通常并发环境下,还是建议采用同步机制。这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止意外的非同步访问。

【LinkedHashMap】
      遍历HashMap所得到的数据是杂乱无章的,这在某些情况下客户需要特定遍历顺序时是十分有用的。比如,这种数据结构很适合构建 LRU 缓存。调用 put 或 get 方法将会访问相应的条目(假定调用完成后它还存在)。putAll 方法以指定映射的条目集合迭代器提供的键-值映射关系的顺序,为指定映射的每个映射关系生成一个条目访问。Sun提供的J2SE说明文档特别规定任何其他方法均不生成条目访问,尤其,collection 集合类的操作不会影响底层映射的迭代顺序。
      LinkedHashMap的实现与 HashMap 的不同之处在于,前者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序通常就是集合中元素的插入顺序。该类定义了header、before与after三个属性来表示该集合类的头与前后“指针”,其具体用法类似于数据结构中的双链表,以删除某个元素为例:

Java代码
  1. private void remove() {   
  2.        before.after = after;   
  3.        after.before = before;   
  4. }  
 

      实际上就是改变前后指针所指向的元素。
      显然,由于增加了维护链接列表的开支,其性能要比 HashMap 稍逊一筹,不过有一点例外:LinkedHashMap的迭代所需时间与其的所包含的元素成比例;而HashMap 迭代时间很可能开支较大,因为它所需要的时间与其容量(分配给Key空间的长度)成比例。一言以蔽之,随机存取用HashMap,顺序存取或是遍历用LinkedHashMap。
      LinkedHashMap还重写了removeEldestEntry方法以实现自动清除过期数据的功能,这在HashMap中是无法实现的,因为后者其内部的元素是无序的。默认情况下,LinkedHashMap中的removeEldestEntry的作用被关闭,其具体实现如下:

Java代码
  1. protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {   
  2.     return false;   
  3. }  
 

       可以使用如下的代码覆盖removeEldestEntry:

Java代码
  1. private static final int MAX_ENTRIES = 100;   
  2.   
  3. protected boolean removeEldestEntry(Map.Entry eldest) {   
  4.     return size() > MAX_ENTRIES;   
  5. }  
 

      它表示,刚开始,LinkedHashMap中的元素不断增长;当它内部的元素超过MAX_ENTRIES(100)后,每当有新的元素被插入时,都会自动删除双链表中最前端(最旧)的元素,从而保持LinkedHashMap的长度稳定。
      缺省情况下,LinkedHashMap采取的更新策略是类似于队列的FIFO,如果你想实现更复杂的更新逻辑比如LRU(最近最少使用) 等, 可以在构造函数中指定其accessOrder为true,因为的访问元素的方法(get)内部会调用一个“钩子”,即recordAccess,其具体实现如下:

Java代码
  1. void recordAccess(HashMap<K,V> m) {   
  2.     LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;   
  3.     if (lm.accessOrder) {   
  4.         lm.modCount++;   
  5.         remove();   
  6.         addBefore(lm.header);   
  7.     }   
  8. }  
 

      上述代码主要实现了这样的功能:如果accessOrder被设置为true,则每次访问元素时,都将该元素移至headr的前面,即链表的尾部。将removeEldestEntry与accessOrder一起使用,就可以实现最基本的内存缓存,具体代码可参考http://bluepopopo.javaeye.com/blog/180236
【WeakHashMap】
      99%的JAVA教材教导我们不要去干预JVM的垃圾回收机制,但JAVA中确实存在着与其密切相关的四种引用:强引用、软引用、弱引用以及幻象引用。
      JAVA中默认的HashMap采用的是采用类似于强引用的强键来管理的,这意味着即使作为key的对象已经不存在了(指没有任何一个引用指向它),也仍然会保留在HashMap中,在某些情况下(例如内存缓存)中,这些过期的条目可能会造成内存泄漏等问题。
      WeakHashMap采用的策略是,只要作为key的对象已经不存在了(超出生命周期),就不会阻止垃圾收集器清空此条目,即使当前机器的内存并不紧张。不过,由于GC是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象,除非你显示地调用它,可以参考下面的例子:

Java代码
  1. public static void main(String[] args) {   
  2.     Map<String, String>map = new WeakHashMap<String, String>();   
  3.     map.put(new String("Alibaba"), "alibaba");   
  4.     while (map.containsKey("Alibaba")) {   
  5.         try {   
  6.             Thread.sleep(500);   
  7.          } catch (InterruptedException ignored) {   
  8.          }   
  9.          System.out.println("Checking for empty");   
  10.          System.gc();   
  11.     }  
 

      上述代码输出一次Checking for empty就退出了主线程,意味着GC在最近的一次垃圾回收周期中清除了new String(“Alibaba”),同时WeakHashMap也做出了及时的反应,将该键对应的条目删除了。如果将map的类型改为HashMap的话,由于其内部采用的是强引用机制,因此即使GC被显示调用,map中的条目依然存在,程序会不断地打出Checking for empty字样。另外,在使用WeakHashMap的情况下,若是将

Java代码
  1. map.put(new String("Alibaba"), "alibaba");   
 

改为

Java代码
  1. map.put("Alibaba""alibaba");   
 

      程序还是会不断输出Checking for empty。这与前面我们分析的WeakHashMap的弱引用机制并不矛盾,因为JVM为了减小重复创建和维护多个相同String的开销,其内部采用了蝇量模式(《JAVA与模式》),此时的“Alibaba”是存放在常量池而非堆中的,因此即使没有对象指向“Alibaba”,它也不会被GC回收。弱引用特别适合以下对象:占用大量内存,但通过垃圾回收功能回收以后很容易重新创建。
      介于HashMap和WeakHashMap之中的是SoftHashMap,它所采用的软引用的策略指的是,垃圾收集器并不像其收集弱可及的对象一样尽量地收集软可及的对象,相反,它只在真正 “需要” 内存时才收集软可及的对象。软引用对于垃圾收集器来说是一种“睁一只眼,闭一只眼”方式,即 “只要内存不太紧张,我就会保留该对象。但是如果内存变得真正紧张了,我就会去收集并处理这个对象。” 就这一点看,它其实要比WeakHashMap更适合于实现缓存机制。遗憾的是,JAVA中并没有实现相关的SoftHashMap类(Apache和Google提供了第三方的实现),但它却是提供了两个十分重要的类java.lang.ref.SoftReference以及ReferenceQueue,可以在对象应用状态发生改变是得到通知,可以参考com.alibaba.common.collection.SofthashMap中processQueue方法的实现:

Java代码
  1. private ReferenceQueue queue = new ReferenceQueue();   
  2. ValueCell vc;   
  3. Map hash = new HashMap(initialCapacity, loadFactor);   
  4. ……   
  5. while ((vc = (ValueCell) queue.poll()) != null) {   
  6. if (vc.isValid()) {   
  7.           hash.remove(vc.key);   
  8.            } else {   
  9.              valueCell.dropped--;   
  10.            }   
  11. }   
  12. }  
 

      processQueue方法会在几乎所有SoftHashMap的方法中被调用到,JVM会通过ReferenceQueue的poll方法通知该对象已经过期并且当前的内存现状需要将它释放,此时我们就可以将其从hashMap中剔除。事实上,默认情况下,Alibaba的MemoryCache所使用的就是SoftHashMap。

<think>嗯,用户想了解HashMap的put方法的原理。结合用户给出的引用内容,我需要整理一下这方面的知识体系。 用户主要在查询HashMap中put方法的实现机制,尤其关注JDK1.8版本的特性。用户提供的引用材料涵盖了数据结构、哈希计算、冲突处理、树化机制等核心内容。 准备从数据结构开始说明,在JDK1.8中HashMap采用数组+链表/红黑树的复合结构。数组作为桶(bucket)存放链表头节点或树根节点,当链表长度超过8且数组容量达到64时会树化。这点在引用[3]中有提到树化转换条件。 哈希计算部分需要强调扰动函数的优化。JDK1.8的hash()方法将key的hashCode高16位与低16位异或,减少哈希碰撞。引用[2]提到使用hashCode()和哈希算法寻找索引,但没说明扰动细节,这点我会补充。 根据引用[3]描述的索引定位公式:(n-1)&hash,其中n是数组长度。这个位运算相当于取模但效率更高。当发生哈希冲突时,引用[2]说明会先判断节点类型:如果是树节点则调用红黑树的插入方法,否则遍历链表并考虑树化条件。 扩容机制方面着重说明引用[3]提到的优化:重新哈希时通过hash&oldCap判断元素位置。若结果为0则保持原索引,否则新位置=原索引+旧容量。这种设计避免重新计算哈希值,提升扩容效率。 最后需要强调线程安全问题。虽然不属于put原理本身,但实际应用很重要。考虑提醒用户HashMap非线程安全,多线程环境下应该用ConcurrentHashMap。 </think>好的,我们来详细解析一下 JDK 1.8 中 HashMap 的 `put(K key, V value)` 方法底层原理。整个过程围绕着**数组+链表/红黑树**的数据结构、**哈希计算**、**哈希冲突解决**以及**扩容**这几个核心概念展开: ### 核心数据结构与流程概述 1. **初始化与底层结构:** * HashMap 内部维护一个 `Node<K, V>[] table` 数组(通常称为桶数组或 bucket array)。`Node` 是存储键值对的基本单元,是一个链表节点(包含 `key`, `value`, `hash`, `next` 指针)。 * 在 JDK 1.8 中,当单个桶(数组元素)中的链表长度过长(达到阈值 8)**且**整个 `table` 数组的长度达到一定阈值(默认 64)时,该桶处的链表会转换为**红黑树(TreeNode)**,以提高极端情况下的查询效率($O(n)$ -> $O(\log n)$)[^1][^2]。 2. **put 方法的核心步骤:** * **Step 1: 计算 Key 的哈希值 (`hash`)** * 调用 `key.hashCode()` 获取原始哈希码。 * 关键优化:HashMap 内部使用 `(h = key.hashCode()) ^ (h >>> 16)` 进行一次**扰动计算**。 * 目的:**降低哈希碰撞**。原始哈希码的高 16 位与低 16 位进行异或,使得高位的变化也能影响最终的低位结果。因为后续确定桶位置时(`(n - 1) & hash`),当 table 长度 `n` 较小时(如初始 16),实际上是只用到了 `hash` 的低几位(如 `n-1=15`,二进制 `1111`,只用了低 4 位)。扰动让高位也参与到影响低位中,让哈希分布更均匀[^1][^2]。 * **Step 2: 计算桶索引 (`index`)** * 使用公式: `index = (table.length - 1) & hash` * 解释: * `table.length - 1`:因为桶数组长度 `n` 总是 2 的幂(如 16, 32, 64...),所以 `n-1` 的二进制表示是一个低位全为 `1` 的数(如 15 -> `1111`, 31 -> `11111`)。 * `& hash`:执行按位与操作 (`&`)。这相当于对 `hash` 值进行 `取模(n)` 操作 (`hash % n`),但效率远高于取模运算。 * 结果:`index` 的值在 `[0, table.length - 1]` 之间,确定了键值对应该放入哪个桶(数组哪个位置)[^1][^2][^3]。 * **Step 3: 处理目标桶 (`table[index]`)** * **情况 A:桶为空 (`table[index] == null`)** * 最简单的情况:直接在该索引位置创建一个新的 `Node` 节点存放键值对即可。 * **情况 B:桶不为空 (哈希碰撞发生)** * 需要遍历该桶位置的链表或红黑树,进行更新或插入: 1. **检查第一个节点:** * 比较 `table[index]` 节点的 `hash` 值和传入的 `hash` 值。 * 如果 `hash` 相等,再用 `equals()` 方法(或 `==`)比较 `key`。 * 如果 `key` 也相等:找到了**重复的 key**!用新 `value` **覆盖**旧的 `value`,返回旧 `value`。 2. **后续节点处理:** * 如果第一个节点不匹配,判断该桶结构是链表还是红黑树: * **如果是红黑树 (`TreeNode`):** 调用红黑树的 `putTreeVal()` 方法进行查找/插入。该方法会按照红黑树的规则查找 key。如果找到相同 key 则覆盖 value,找不到则插入新节点并在必要时调整树结构以保持平衡。 * **如果是链表:** 遍历链表(`next` 指针): * 对每个节点,重复步骤 1 的比较(`hash` 和 `key.equals()`)。 * 如果找到相同 key:覆盖 value,返回旧 value。 * 如果遍历到链表尾部 (`next == null`) 仍未找到:在链表尾部插入一个新的 `Node` 节点。 * **链表树化检查:** 插入新节点后,检查当前链表长度: * 如果插入后链表长度 **>= 8** (`TREEIFY_THRESHOLD = 8`),则调用 `treeifyBin(tab, hash)` 尝试将该链表转换为红黑树。 * 在 `treeifyBin` 方法中,会**先检查当前 `table` 的长度**: * 如果 `table` 长度 **< 64** (`MIN_TREEIFY_CAPACITY = 64`),则不进行树化,而是优先选择**扩容 (`resize()`)**,因为扩容本身就能分散节点,可能解决链表过长问题。 * 如果 `table` 长度 **>= 64**,则真正将该桶处的链表节点转换为 `TreeNode` 节点,并构建成一颗红黑树[^1][^2]。 * **Step 4: 修改计数与扩容检查** * 每次成功添加新节点(而非覆盖旧值)后,`size`(HashMap 中键值对总数)会加 1。 * 检查 `size` 是否超过了 `threshold`(阈值 = `capacity * loadFactor`,`capacity` 是 `table.length`,`loadFactor` 是负载因子,默认 0.75)。 * 如果 `size > threshold`:触发 **`resize()`** 扩容操作。 3. **扩容 (`resize()`) 原理:** * **目的:** 增大桶数组容量,减少哈希碰撞,为链表树化或保持性能提供空间。 * **过程:** 1. 创建一个新的 `Node` 数组,其容量是原数组容量的 **2 倍**(`newCap = oldCap << 1`)。 2. 遍历原数组的每一个桶(链表或树)。 3. 将原桶中的每个节点重新计算其在新数组中的位置(`(newCap - 1) & hash`),并将其移动到新数组对应的桶中。 * **JDK 1.8 优化:** 重新计算索引 (`index`) 时,利用了一个特性: * 因为 `newCap = oldCap * 2`(是 2 的幂),所以 `newCap - 1` 的二进制表示比 `oldCap - 1` 多一个高位 `1`(例如,16 -> 15(`1111`), 扩容到 32 -> 31(`11111`))。 * 节点在新数组中的位置只可能是两个值之一:**原位置 `oldIndex`** 或 **原位置 + 原容量 `oldIndex + oldCap`**。 * **判断依据:** 计算 `(e.hash & oldCap)`(注意这里是 `oldCap`,不是 `oldCap-1`): * 结果为 **0**:节点在新数组中的位置 = **原位置 (`oldIndex`)**。 * 结果**不为 0**:节点在新数组中的位置 = **原位置 + 原容量 (`oldIndex + oldCap`)**。 * **优点:** 省去了重新计算 `hash` 值的开销(`hash` 值本身不变),只需要判断一次位运算 (`hash & oldCap`) 的结果,就可以高效地将节点分配到新数组的两个桶中(要么在原桶,要么在原桶+oldCap 位置)。这个优化显著提升了扩容效率[^3]。 * 树节点在移动过程中,如果树变得太小(节点数 <= `UNTREEIFY_THRESHOLD = 6`),会被转换回链表。 ### 总结流程图 ```mermaid graph TD A[调用 putkey, value] --> B[计算 key 的 hash = key.hashCode ^ hashCode>>>16] B --> C[计算桶索引 index = n-1 & hash] C --> D{桶 table[index]为空?} D -- Yes --> E[创建新 Node 插入] D -- No --> F{桶中第一个节点 key 匹配? hash 和 equals} F -- Yes --> G[覆盖 value] F -- No --> H{桶结构是红黑树?} H -- Yes --> I[调用 putTreeVal 插入/覆盖] I -- 新节点插入? --> J[增加 size] H -- No 链表 --> K[遍历链表] K --> L{找到相同 key?} L -- Yes --> M[覆盖 value] L -- No 到尾节点 --> N[尾部插入新 Node] N --> O[增加 size] O --> P{链表长度 >= 8?} P -- Yes --> Q{table 长度 >= 64?} Q -- Yes --> R[转换链表为红黑树] Q -- No --> S[优先触发 resize 扩容] P -- No --> T[继续后续流程] E & J & R --> U[增加 size] U --> V{size > threshold?} V -- Yes --> W[触发 resize 扩容] V -- No --> X[流程结束] G & M -- 覆盖操作 --> X ``` ### 关键点回顾 1. **扰动哈希:** `(h = key.hashCode()) ^ (h >>> 16)` 提升散列均匀性。 2. **索引计算:** `index = (table.length - 1) & hash` 高效定位桶。 3. **冲突解决:** 通过链表或红黑树解决同一个桶内的哈希冲突。 4. **树化条件:** 链表长度 >= 8 **且** 数组长度 >= 64 时才树化,否则优先扩容。 5. **扩容触发:** `size > threshold` (capacity * loadFactor)。 6. **扩容优化:** 利用 `(e.hash & oldCap) == 0` 高效定位节点在新数组的位置(原位置或原位置+oldCap),避免重新计算hash。 7. **覆盖规则:** Key 相同(`hash` 相等且 `key.equals()` 为 true)时覆盖旧值。 通过以上步骤和优化,HashMap 的 `put` 方法能够在平均情况下实现高效的 $O(1)$ 时间复杂度(常数时间)的插入操作,并在冲突较多时通过链表或红黑树维持可接受的性能。
评论 3
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值