HashMap 的实现原理和扩容原理是怎样的?

1,749 阅读12分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

一、实现原理

1.1 哈希表

1.1.1 概述

要学习 hashmap,就不得不提哈希表。

维基百科定义如下:

散列表Hash table,也叫哈希表),是根据(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表

在 java 中,保存数据有两种比较简单的数据结构:数组 和 链表。

  • 数组:寻址容易,插入和删除困难;
  • 链表:寻址困难,但插入和删除容易。

它是把数组和链表这两种结构结合在一起,发挥出各自的优势,这种结构就是哈希表。

img

1.1.2 哈希冲突

何为 hash 冲突,当我们对某个元素插入之后,发现该位置已经被其他元素占用了。这就是哈希碰撞,这种情况是不可避免的,比如著名的抽屉原理,在三个抽屉里面放入四个苹果,那么至少有一个抽屉不少于一个苹果。这就是冲突

哈希冲突解决的方法有很多种,常见的有:

  • 开放定址法
  • 再散列法
  • 链地址法:hashmap 采用的此方法。

hashMap 正是基于哈希表的 map 接口的非同步实现。此实现提供了所有可选的映射操作,并允许使用 null 值和 null 键,但它不保证映射的顺序。

1.2 存储分析

hashmap 的主干是一个 Node 数组,里面存放了很多 Node 元素,Node 是 HashMap 的基本组成单元,每个 Node 包含一个key-value 的键值对。

    /**
      * 第一次使用初始化表,并将大小调整为必要的。分配时,长度总是2的幂
      */
     transient Node<K,V>[] table;  // transient 关键字是防止属性被序列化

这里面的每个小黑点就是 Node 节点。

image-20210702112738034

Node 它是 HashMap 中的一个 静态内部类,实现了 Map.Entry 接口,代码如下:

 /**
      * Basic hash bin node, used for most entries.  (See below for
      * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
      */
     static class Node<K,V> implements Map.Entry<K,V> {
         final int hash; // 用来定位数组索引位置
         final K key;
         V value;
         Node<K,V> next; // 链表的下一个node节点
 ​
         Node(int hash, K key, V value, Node<K,V> next) {
             this.hash = hash;
             this.key = key;
             this.value = value;
             this.next = next;
         }
 ​
         public final K getKey()        { return key; }
         public final V getValue()      { return value; }
         public final String toString() { return key + "=" + value; }
 ​
         public final int hashCode() {
             return Objects.hashCode(key) ^ Objects.hashCode(value);
         }
 ​
         public final V setValue(V newValue) {
             V oldValue = value;
             value = newValue;
             return oldValue;
         }
 ​
         public final boolean equals(Object o) {
             if (o == this)
                 return true;
             if (o instanceof Map.Entry) {
                 Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                 if (Objects.equals(key, e.getKey()) &&
                     Objects.equals(value, e.getValue()))
                     return true;
             }
             return false;
         }
     }

1.3 hash 算法

哈希桶的数组如果很大,那么分配的元素就会很分散,占用更多空间;如果哈希桶的数组很小,就会发生多次hash冲突。

如何控制空间更少 的同时碰撞过少,就需要让哈希桶的大小在一个合理的范围内,这就需要学习 Hash 算法。

hash 算法本质上分为三步:

  • 1、取 key 的 hashCode 值

    问题1:java中对象都已一个hashCode()方法,那为什么还需要hash函数呢?

    答:hashcode 返回值为int 类型且长度不定,为了满足理想的散列表数组大小,我们需要将 hashcode 值转换唯一定长的hash值。

  • 2、高位运算

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

即 key 如果是 null ,那么就放在 Node 数组的第一个位置,下标为0。

问题2:为什么计算hashcode 的时候要将hashCode 右移16位?

答:我们先通过一个例子来了解下这计算过程,需要注意的是异或是相同为0,不同为1.

 假设 h=   0010  0101  1010  1100  0011  1111  0010  1110
 
 右移16位  0000  0000  0000  0000  0010  0101  1010  1100
 
 异或结果: 0010  0101  1010  1100  0001  1010  1000  0010

分析:将 h 无符号右移16位,再与原来的h异或,相当于计算出来hashcode的值 结合了高位特征和低位特征,增加了hashcode 的随机性,在后续计算数组中的位置的时候更有参考性,这也是 设计者的一个细节。

  • 3、取模运算,计算出在数组中的位置。

     e = tab[index = (n - 1) & hash]
    

问题3:为什么计算数组下标位置的时候,n必须为2的幂次方?

答:因为是按位与运算,那就得保证低位都是1,才能计算出每个元素在数组中的位置,而2的n次方-1 刚好低 n 位全是1。

为了能够让 HashMap 存取高效,尽量较少碰撞,也就是尽量把数据分配均匀。

1.4 put 方法源码分析

1、找到put 方法,里面调用了putVal ()方法

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

2、hash 方法我们前面也分析过了,通过高16位和原来的 hashcode 进行异或运算得到一个理想的hashcode值。

3、查看putVal ()方法

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                    boolean evict) {
         /**
         * tab: node 数组
         * p  : 表示当前散列表的元素
         * n  : 表示散列表数组的长度
         * i  : 寻址下标
         */
         Node<K,V>[] tab; Node<K,V> p; int n, i;
     
         /**
         * 如果这个散列表没有创建,或者长度为0,则初始化
         */
         if ((tab = table) == null || (n = tab.length) == 0)
             n = (tab = resize()).length;
     
         /**
         * 1、(n - 1) & hash 就是找当前要插入的数据应该在哈希表中的位置
         * 2、找出要插入的位置的元素,如果为null,就把 k-v的node节点放进去,
         */
         if ((p = tab[i = (n - 1) & hash]) == null)
             tab[i] = newNode(hash, key, value, null);
         // 如果这个位置有数据,则进入 else
         else {
             /**
             * e: 声明一个节点
             * k:表示临时的一个key
             */
             Node<K,V> e; K k;
             
             // 如果当前位置上的那个数据的 hash 和我们要插入的 hash 是一样,代表没有放错位置
             if (p.hash == hash &&  
             // 如果当前这个数据的 key 和我们要放的 key 是一样的,实际操作应该就是替换值
                 ((k = p.key) == key || (key != null && key.equals(k))))
             // 将当前节点赋值给局部变量 e
                 e = p;
             
             // 如果当前节点的 key 和要插入的 key 不一样,然后要判断当前节点是不是一个红黑树类,如果是,就创建一个新节点把数据放进去
             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;
                     }
                         // 如果当前遍历的数据和要插入的数据的key 是一样的,就把value替换
                     if (e.hash == hash &&
                         ((k = e.key) == key || (key != null && key.equals(k))))
                         break;
                     p = e;
                 }
             }
             // 如果当前的节点不等于空,就把当前节点的值赋值给 oldvalue
             if (e != null) { // existing mapping for key
                 V oldValue = e.value;
                 if (!onlyIfAbsent || oldValue == null)
                     e.value = value; // 把当前要插入的 value 替换当前的节点里面值
                 afterNodeAccess(e);
                 return oldValue;
             }
         }
         // 长度+1
         ++modCount;
         if (++size > threshold)
             // 如果长度超过阈值,重新扩容
             resize();
         afterNodeInsertion(evict);
         return null;
     }

小结:

JDK1.8 之前的 hashMap 插入是在链表的头部插入,而 JDK1.8 开始,就是采用了尾部插入。这一点将在第四小节 hashMap 的死循环中讲解。

首先,hashMap 里面 的put 方法,是套了一个putVal 方法。

  • 1、put 方法会将传入的key进行 hash运算,然后在经过n-1 的与运算之后得到数组的下标位置。
  • 2、如果没有发生碰撞,就把value放到数组的当前下标位置
  • 3、如果发生了碰撞,且是在树节点下面,就遍历树节点,将值对比放进去
  • 4、如果是在链表,就遍历链表,将节点替换或插入,然后判断当前链表是否需要转成红黑树。
  • 5、最后,map的数值+1,判断当前长度是否超过阈值(容量* 负载因子),如果超过了,就扩容。

1.5 get 方法源码分析

 public V get(Object key) {
     Node<K,V> e;
     return (e = getNode(hash(key), key)) == null ? null : e.value;
 }
 final Node<K,V> getNode(int hash, Object key) {
         // tab :引用当前hashMap 的散列表
         // first : 桶位中的头元素
         // e : 临时node元素
         // n : table 数组长度
         Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
     
         if ((tab = table) != null && (n = tab.length) > 0 &&
             (first = tab[(n - 1) & hash]) != null) {
             
             // 情况1:定位出来的元素就是我们要get 的元素
             if (first.hash == hash && // always check first node
                 ((k = first.key) == key || (key != null && key.equals(k))))
                 return first;
             
             // 情况2:当前桶位有元素,链表或红黑树
             if ((e = first.next) != null) {
                 if (first instanceof TreeNode)
                     return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
                 do {
                     if (e.hash == hash &&
                         ((k = e.key) == key || (key != null && key.equals(k))))
                         return e;
                 } while ((e = e.next) != null);
             }
         }
         return null;
     }
     

小结:

  • 1、如果数组不为null 且长度大于0,查找数组的第一个节点是否匹配,如果找到了就返回该桶内的值。
  • 2、如果没有找到,就去数组中元素的下一个节点的数据,分别遍历链表或红黑树。

1.6 总结

HashMap 是基于 Hash 算法实现的

  • 1、当我们往 hashmap 中 put 元素时,利用 key 的 hashcode 重新 hash 计算出当前对象的元素在数组中的下标

  • 2、存储时,如果出现 hash 值相同的key,此时有两种情况。

    • 1、如果key 相同,就覆盖原始值。
    • 2、如果key 不同,就将冲突的key对象放在链表中。
  • 3、获取时,直接找到 hash 值对应的下标,在进一步判断 key 是否相同,从而找到对应值。

  • 4、理解了以上过程就不难明白 hashMap 是如何解决hash冲突的,核心就是使用了数组的存储方式,然后将冲突的对象放入链表中,一旦发现冲突就在链表上做进一步对比。需要注意的是 jdk1.8之后,在链表长度超过8,总长度大于64之后,会将链表转成红黑树优化查询效率。

二、扩容机制

在扩容机制中,需要明确几个概念

 1、默认的初始化容量大小为16,必须为 2 的幂次方
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
 ​
 2、最大的容量,在两个带参数的构造函数隐式指定更高值时使用,必须为 2的幂次方
    static final int MAXIMUM_CAPACITY = 1 << 30;
 ​
 3、默认的负载因数,这个值最好不要改动,这个值可以大于1.
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
   
 4、链表转红黑树的阈值:
    static final int TREEIFY_THRESHOLD = 8;
    
 5、红黑树退化成链表的阈值:
    static final int UNTREEIFY_THRESHOLD = 6;
    
 6、当哈希表的容量大于64时,才允许树化(链表转红黑树),如果小于64,则直接扩容,这个值不能小于4*TREEIFY_THRESHOLD。不然会进行选择扩容还是树化。
     static final int MIN_TREEIFY_CAPACITY = 64;

2.1 什么时候发生扩容

在jdk1.8中,当元素数量超过阈值(容量*负载因子)时,一般就会发生扩容,每次扩容的容量都是之前容量的2 倍数。

HashMap的容量是有上限的,必须小于1<<30。如果容量超出了这个数,则不再增长,且阈值会被设置为Integer.MAX_VALUE

2.2 jdk1.8 中的扩容机制

为什么要扩容?

为了解决哈希冲突导致的链化影响查询效率的问题,扩容会缓解该问题。

扩容是一件很耗性能的操作,数组是无法自动扩容的,所以我们需要重新计算出新的容量,然后再创建新的数组,并将所有的元素拷贝到新的数组中,并释放旧数组的数据。

扩容数组之后为原来的两倍,(n-1)& hash 也就变成了(2n-1)& hash,所以在之前是因为后 n 位在同一个链表上的元素 ,现在就要与 n+1 位 & 运算,得出的第 n+1 位为0 或1 ,

造成两种结果:一种是没有影响还在当前数组下标位置;另一种是原位置+扩容长度。

扩容的 resize 算法如下:

 final Node<K,V>[] resize() {
         // oldTab : 引用之前的哈希表
         Node<K,V>[] oldTab = table;
         // oldCap : 表示扩容之前 table 数组的长度
         int oldCap = (oldTab == null) ? 0 : oldTab.length;
         // oldThr : 表示扩容之前的阈值
         int oldThr = threshold;
         // newCap :扩容之后的大小,newThr:扩容后的阈值
         int newCap, newThr = 0;
         
         // 条件如果成立,hashMap中的散列表已经初始化过了,是一次正常扩容
         if (oldCap > 0) {
             // 如果扩容值超过最大值,就没有扩容
             if (oldCap >= MAXIMUM_CAPACITY) {
                 threshold = Integer.MAX_VALUE;
                 return oldTab;
             }
             // oldCap 左移翻倍,并且赋值给 newCap,newCap 小于数组最大值限制,且扩容之前的阈值 >=16
             // 这种情况,下次扩容的阈值翻倍
             else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                      oldCap >= DEFAULT_INITIAL_CAPACITY)
                 newThr = oldThr << 1; // double threshold
         }
         // oldCap == 0 ,说明hashmap中的散列表是 null,对应三种构造函数
         else if (oldThr > 0) // initial capacity was placed in threshold
             newCap = oldThr;
      
         // oldThr ==0 oldCap ==0 对应构造函数 new hashMap()
         else {               // zero initial threshold signifies using defaults
             newCap = DEFAULT_INITIAL_CAPACITY;
             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
         }
         
         // newThr 为零时,通过 newCap 和负载因素 计算出一个新阈值。
         if (newThr == 0) {
             float ft = (float)newCap * loadFactor;
             newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                       (int)ft : Integer.MAX_VALUE);
         }
         threshold = newThr;
      
      // 下面进行正式扩容
         @SuppressWarnings({"rawtypes","unchecked"})
         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
         table = newTab;
      
         // 说明,hashmap 本次扩容之前,table不为null
         if (oldTab != null) {
             
             for (int j = 0; j < oldCap; ++j) {
                 Node<K,V> e;
                 // 说明当前桶位有数据,
                 if ((e = oldTab[j]) != null) {
                     // 回收内存
                     oldTab[j] = null;
                     // 如果它没有指向任何元素,就根据新的数组长度重新计算出在新数组的位置放进去
                     if (e.next == null)
                         newTab[e.hash & (newCap - 1)] = e;
                     
                     // 判断当前节点已经树化
                     else if (e instanceof TreeNode)
                         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                     
                     // 不然就是链表内的元素
                     else { // preserve order
                         // 低位链表:存放在扩容之后的数组的下标位置,与当前数组的下标位置一致。
                         Node<K,V> loHead = null, loTail = null;
                         // 高位链表:存放在扩容之后的数组的下标位置 + 扩容之前数组的长度
                         Node<K,V> hiHead = null, hiTail = null;
                         
                         Node<K,V> next;
                         do {
                             next = e.next;
                             if ((e.hash & oldCap) == 0) {
                                 if (loTail == null)
                                     loHead = e;
                                 else
                                     loTail.next = e;
                                 loTail = e;
                             }
                             else {
                                 if (hiTail == null)
                                     hiHead = e;
                                 else
                                     hiTail.next = e;
                                 hiTail = e;
                             }
                         } while ((e = next) != null);
                         if (loTail != null) {
                             loTail.next = null;
                             newTab[j] = loHead;
                         }
                         if (hiTail != null) {
                             hiTail.next = null;
                             newTab[j + oldCap] = hiHead;
                         }
                     }
                 }
             }
         }
         return newTab;
     }

在putval()中,我们看到在这个函数里面使用了2 次 resize()方法,resize()方法表示的在进行第一次初始化的时候会进行扩容。jdk1.8 之中做的优化就是对同一个桶的位置的进行判断,该元素的位置要么停留在原始位置,要么停止在原始位置+数组大小位置上。而1.7是重新计算hash值,根据hash 值对其进行分发。

扩容小结:

  • 在 jdk1.8 中 ,resize 方法 是在 hashmap 中的键值对大于阈值或者初始化时,就调用 resize 方法进行扩容
  • 在每次扩容的时候,都是扩展 2倍
  • 在扩展后 Node 对象的位置要么在原位置,要么移动到原偏移量两倍的位置
  • 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
  • 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。

三、最后

以上,就是有关 hashmap 实现原理和扩容机制的个人讲解。感谢阅读,更文不易,请多支持。