Java集合之HashMap

210 阅读9分钟

本文主要用作个人学习笔记,包含大量摘抄,学习建议去参考链接的大佬文章,有误欢迎指正~

1什么是HashMap?

HashMap的底层数据结构是数组+链表,在JDK1.7和1.8有重大改动,在1.7
及之前是数组+单链表

在1.8及以后是数组+链表及红黑树

发生Hash碰撞时1.7只是将新结点加入链表,而1.8会在链表结点超出8(默认)个时将链表转变为红黑树

2HashMap的构造方法

初始容量与装载因子对HashMap尤为重要,因此HashMap的构造方法也可按这两个参数的有无来区分:有3个构造方法:

    1.是没有指定这两个参数的无参构造
    结果是指定了装载因子为默认的0.75
    2.是指定了容量以及装载因子的构造方法
    根据期望容量返回一个 >= cap 的扩容阈值,并且这个阈值一定是 2^n
    结果是得到一个根据指定容量计算而来的扩容阈值及装载因子
    3.是只指定了容量的构造方法
    结果是将指定容量及默认装载因子代入第二种构造方法
    4.是指定某集合的构造方法
    暂时不去了解

3HashMap如何确定添加元素的位置(只看结论):

1.在存储元素之前,HashMap 会对 key 的 hashCode 返回值做进一步扰动函数处理,1.7 中扰动函数使用了 4次位运算 + 5次异或运算,1.8 中降低到 1次位运算 + 1次异或运算
2.扰动处理后的 hash 与 哈希表数组length -1 做位与运算得到最终元素储存的哈希桶角标位置。

作者:像一只狗
链接:https://juejin.cn/post/6844903588179755021
来源:掘金

4HashMap的添加元素

// 可以看到具体的添加行为在 putVal 方法中进行
public V put(K key, V value) {
   return putVal(hash(key), key, value, false, true);
}

第4个参数 onlyIfAbsent 表示只有当对应 key 的位置为空的时候替换元素,一般传 false,在 JDK1.8中新增方法 public V putIfAbsent(K key, V value) 传 true,第 5 个参数 evict 如果是 false。那么表示是在初始化时调用的:

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
          boolean evict) {
          
   Node<K,V>[] tab; Node<K,V> p; int n, i;
   //如果是第一添加元素 table = null 则需要扩容
   if ((tab = table) == null || (n = tab.length) == 0)
       n = (tab = resize()).length;// n 表示扩容后数组的长度
   //  i = (n - 1) & hash 即上边讲得元素存储在 map 中的数组角标计算
   // 如果对应数组没有元素没发生 hash 碰撞 则直接赋值给数组中 index 位置   
   if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
   else {// 发生 hash 碰撞了
       Node<K,V> e; K k;
        //如果对应位置有已经有元素了 且 key 是相同的则覆盖元素
       if (p.hash == hash &&
           ((k = p.key) == key || (key != null && key.equals(k))))
           e = p;
       else if (p instanceof TreeNode)//如果添加当前节点已经为红黑树,则需要转为红黑树中的节点
           e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
       else {// hash 值计算出的数组索引相同,但 key 并不同的时候,        // 循环整个单链表
           for (int binCount = 0; ; ++binCount) {
               if ((e = p.next) == null) {//遍历到尾部
                    // 创建新的节点,拼接到链表尾部
                   p.next = newNode(hash, key, value, null);             // 如果添加后 bitCount 大于等于树化阈值后进行哈希桶树化操作
                   if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                       treeifyBin(tab, hash);
                   break;
               }
               //如果遍历过程中找到链表中有个节点的 key 与 当前要插入元素的 key 相同,此时 e 所指的节点为需要替换 Value 的节点,并结束循环
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   break;
               //移动指针    
               p = e;
           }
       }
       //如果循环完后 e!=null 代表需要替换e所指节点 Value
       if (e != null) { // existing mapping for key
           V oldValue = e.value//保存原来的 Value 作为返回值
           // onlyIfAbsent 一般为 false 所以替换原来的 Value
           if (!onlyIfAbsent || oldValue == null)
               e.value = value;
            //这个方法在 HashMap 中是空实现,在 LinkedHashMap 中有关系   
           afterNodeAccess(e);
           return oldValue;
       }
   }
   //操作数增加
   ++modCount;
   //如果 size 大于扩容阈值则表示需要扩容
   if (++size > threshold)
       resize();
   afterNodeInsertion(evict);
   return null;
}


1.检查容量是否足够,不足则调用resize()进行扩容
2.检查K的hash值计算出桶的角标
3.查看对应桶中是否有值无则直接插入
4.有则比较K值是否相等(已存在),相等则覆盖V值
5.K值不相等则查看链表是否为红黑树,是则加入新的树结点,否则加入链表
6.遍历链表加入新的V值时若遇到相等的K值则替换对应K值结点的V值
7.加入链表完毕则检查链表结点是否大于8,是则转换为红黑树
8.数组实际大小+1然后看是否超出扩容阈值,是则resize()扩容

图解:

图片来自:tech.meituan.com/java-hashma…

5hashCode方法返回的是对象的内存地址吗?

object基类的hashCode方法默认返回对象的内存地址,
但是在一些场景下改方法会被重写,此时返回的就不是

6HashMap扩容:

final Node<K,V>[] resize() {
   // oldTab 指向旧的 table 表
   Node<K,V>[] oldTab = table;
   // oldCap 代表扩容前 table 表的数组长度,oldTab 第一次添加元素的时候为 null 
   int oldCap = (oldTab == null) ? 0 : oldTab.length;
   // 旧的扩容阈值
   int oldThr = threshold;
   // 初始化新的阈值和容量
   int newCap, newThr = 0;
   // 如果 oldCap > 0 则会将新容量扩大到原来的2倍,扩容阈值也将扩大到原来阈值的两倍
   if (oldCap > 0) {
       // 如果旧的容量已经达到最大容量 2^30 那么就不在继续扩容直接返回,将扩容阈值设置到 Integer.MAX_VALUE,并不代表不能装新元素,只是数组长度将不会变化
       if (oldCap >= MAXIMUM_CAPACITY) {
           threshold = Integer.MAX_VALUE;
           return oldTab;
       }//新容量扩大到原来的2倍,扩容阈值也将扩大到原来阈值的两倍
       else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
           newThr = oldThr << 1; // double threshold
   }
   //oldThr 不为空,代表我们使用带参数的构造方法指定了加载因子并计算了
   //初始初始阈值 会将扩容阈值 赋值给初始容量这里不再是期望容量,
   //但是 >= 指定的期望容量
   else if (oldThr > 0) // initial capacity was placed in threshold
       newCap = oldThr;
   else {
        // 空参数构造会走这里初始化容量,和扩容阈值 分别是 16 和 12
       newCap = DEFAULT_INITIAL_CAPACITY;
       newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
   }
   //如果新的扩容阈值是0,对应的是当前 table 为空,但是有阈值的情况
   if (newThr == 0) {
        //计算新的扩容阈值
       float ft = (float)newCap * loadFactor;
       // 如果新的容量不大于 2^30 且 ft 不大于 2^30 的时候赋值给 newThr 
       //否则 使用 Integer.MAX_VALUE
       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;
   //如果老的数组不为空将进行重新插入操作否则直接返回
   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
                   //因为扩容是容量翻倍,
                   //原链表上的每个节点 现在可能存放在原来的下标,即low位,
                   //或者扩容后的下标,即high位
              //低位链表的头结点、尾节点
              Node<K,V> loHead = null, loTail = null;
              //高位链表的头节点、尾节点
              Node<K,V> hiHead = null, hiTail = null;
              Node<K,V> next;//用来存放原链表中的节点
              do {
                  next = e.next;
                  // 利用哈希值 & 旧的容量,可以得到哈希值去模后,
                  //是大于等于 oldCap 还是小于 oldCap,
                  //等于 0 代表小于 oldCap,应该存放在低位,
                  //否则存放在高位(稍后有图片说明)
                  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);
              //将低位链表存放在原index处,
              if (loTail != null) {
                  loTail.next = null;
                  newTab[j] = loHead;
              }
              //将高位链表存放在新index处
              if (hiTail != null) {
                  hiTail.next = null;
                  newTab[j + oldCap] = hiHead;
              }
           }
       }
   }
   return newTab;
}

过程:

整体分为两部分:
1. 寻找扩容后数组的大小以及新的扩容阈值
2. 将原有哈希表拷贝到新的哈希表中。

oldCap旧的容量,oldThr旧阈值
1> oldCap>0?
2>:
    (1)oldCap>最大容量?是则oldCap不变,令阈值为最大整形值并返回
    (2)(oldCap>>1)<MAX容量同时oldCap>初始容量(16)时令阈值>>1
    (3)oldThr>0即使用带参构造函数指定了加载因子并计算了初始阈值时new Cap=oldThr
    (4)使用无参构造函数时都取默认值cap=16,thr=12
3>newThr==0?不懂这步
4>更新全局的阈值
5>使用新的容量创建哈希表数组
6>判断旧表是否为空,是则进行重新插入操作,否则返回

以下摘自:https://mp.weixin.qq.com/s?__biz=MzI4Njg5MDA5NA==&mid=2247484139&idx=1&sn=bb73ac07081edabeaa199d973c3cc2b0&chksm=ebd743eadca0cafc532f298b6ab98b08205e87e37af6a6a2d33f5f2acaae245057fa01bd93f4#rd

7.HashMap的get方法

然后看下getNode方法:

8.remove方法:

然后康康removeNode方法:

惯例要写下与HashTable的区别:

1.HashMap 是线程不安全的,HashTable是线程安全的。
2.HashMap 允许 key 和 Vale 是 null,但是只允许一个 key 为 null,且这个元素存放在哈希表 0 角标位置。 HashTable 不允许key、value 是 null
3.HashMap 内部使用hash(Object key)扰动函数对 key 的 hashCode 进行扰动后作为 hash 值。HashTable 是直接使用 key 的 hashCode() 返回值作为 hash 值。
4.HashMap默认容量为 2^4 且容量一定是 2^n ; HashTable 默认容量是11,不一定是 2^n
5.HashTable取哈希桶下标是直接用模运算,扩容时新容量是原来的2倍+1。HashMap在扩容的时候是原来的两倍,且哈希桶的下标使用 &运算代替了取模。

参考:

像一只狗 掘金博客:搞懂 Java HashMap 源码
java3y 微信文章:HashMap就是这么简单【源码剖析】