看完这篇 HashMap ,和面试官扯皮就没问题了(3)

简介: 看完这篇 HashMap ,和面试官扯皮就没问题了(3)

HashMap 构造函数


在 HashMap 源码中,有四种构造函数,分别来介绍一下


  • 带有初始容量 initialCapacity负载因子 loadFactor 的构造函数


public HashMap(int initialCapacity, float loadFactor) {
  if (initialCapacity < 0)
    throw new IllegalArgumentException("Illegal initial capacity: " +
                                       initialCapacity);
  if (initialCapacity > MAXIMUM_CAPACITY)
    initialCapacity = MAXIMUM_CAPACITY;
  if (loadFactor <= 0 || Float.isNaN(loadFactor))
    throw new IllegalArgumentException("Illegal load factor: " +
                                       loadFactor);
  this.loadFactor = loadFactor;
  // 扩容的阈值
  this.threshold = tableSizeFor(initialCapacity);
}


初始容量不能为负,所以当传递初始容量 < 0 的时候,会直接抛出 IllegalArgumentException 异常。如果传递进来的初始容量 > 最大容量时,初始容量 = 最大容量。负载因子也不能小于 0 。然后进行数组的扩容,这个扩容机制也非常重要,我们后面进行探讨


  • 只带有 initialCapacity 的构造函数


public HashMap(int initialCapacity) {
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}


最终也会调用到上面的构造函数,不过这个默认的负载因子就是 HashMap 的默认负载因子也就是 0.75f


  • 无参数的构造函数


public HashMap() {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
}


默认的负载因子也就是 0.75f


  • 带有 map 的构造函数


public HashMap(Map<? extends K, ? extends V> m) {
  this.loadFactor = DEFAULT_LOAD_FACTOR;
  putMapEntries(m, false);
}


带有 Map 的构造函数,会直接把外部元素批量放入 HashMap 中。


讲一讲 HashMap put 的全过程


我记得刚毕业一年去北京面试,一家公司问我 HashMap put 过程的时候,我支支吾吾答不上来,后面痛下决心好好整。以 JDK 1.8 为基准进行分析,后面也是。先贴出整段代码,后面会逐行进行分析。


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 或者没有为 table 分配内存,就resize一次
  if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  // 指定hash值节点为空则直接插入,这个(n - 1) & hash才是表中真正的哈希
  if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
  // 如果不为空
  else {
    Node<K,V> e; K k;
    // 计算表中的这个真正的哈希值与要插入的key.hash相比
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
      e = p;
    // 若不同的话,并且当前节点已经在 TreeNode 上了
    else if (p instanceof TreeNode)
      // 采用红黑树存储方式
      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
    // key.hash 不同并且也不再 TreeNode 上,在链表上找到 p.next==null
    else {
      for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
          // 在表尾插入
          p.next = newNode(hash, key, value, null);
          // 新增节点后如果节点个数到达阈值,则进入 treeifyBin() 进行再次判断
          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
          break;
        }
        // 如果找到了同 hash、key 的节点,那么直接退出循环
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
          break;
        // 更新 p 指向下一节点
        p = e;
      }
    }
    // map中含有旧值,返回旧值
    if (e != null) { // existing mapping for key
      V oldValue = e.value;
      if (!onlyIfAbsent || oldValue == null)
        e.value = value;
      afterNodeAccess(e);
      return oldValue;
    }
  }
  // map调整次数 + 1
  ++modCount;
  // 键值对的数量达到阈值,需要扩容
  if (++size > threshold)
    resize();
  afterNodeInsertion(evict);
  return null;
}


首先看一下 putVal 方法,这个方法是 final 的,如果你自已定义 HashMap 继承的话,是不允许你自己重写 put 方法的,然后这个方法涉及五个参数


  • hash -> put 放在桶中的位置,在 put 之前,会进行 hash 函数的计算。
  • key -> 参数的 key 值
  • value -> 参数的 value 值
  • onlyIfAbsent -> 是否改变已经存在的值,也就是是否进行 value 值的替换标志
  • evict -> 是否是刚创建 HashMap 的标志


在调用到 putVal 方法时,首先会进行 hash 函数计算应该插入的位置


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


哈希函数的源码如下


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


首先先来理解一下 hash 函数的计算规则


Hash 函数


hash 函数会根据你传递的 key 值进行计算,首先计算 key 的 hashCode 值,然后再对 hashcode 进行无符号右移操作,最后再和 hashCode 进行异或 ^ 操作。


>>>: 无符号右移操作,它指的是 「无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0」 ,也就是不管是正数还是负数,右移都会在空缺位补 0 。


在得到 hash 值后,就会进行 put 过程。


首先会判断 HashMap 中的 Node 数组是否为 null,如果第一次创建 HashMap 并进行第一次插入元素,首先会进行数组的 resize,也就是重新分配,这里还涉及到一个 resize() 扩容机制源码分析,我们后面会介绍。扩容完毕后,会计算出 HashMap 的存放位置,通过使用 「( n - 1 ) & hash」 进行计算得出。


image.png


然后会把这个位置作为数组的下标作为存放元素的位置。如果不为空,那么计算表中的这个真正的哈希值与要插入的 key.hash 相比。如果哈希值相同,key-value 不一样,再判断是否是树的实例,如果是的话,那么就把它插入到树上。如果不是,就执行尾插法在 entry 链尾进行插入。


image.png


会根据桶中元素的数量判断是链表还是红黑树。然后判断键值对数量是否大于阈值,大于的话则进行扩容。


扩容机制


在 Java 中,数组的长度是固定的,这意味着数组只能存储固定量的数据。但在开发的过程中,很多时候我们无法知道该建多大的数组合适。好在 HashMap 是一种自动扩容的数据结构,在这种基于变长的数据结构中,扩容机制是非常重要的。


在 HashMap 中,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。HashMap 中的扩容机制是由 resize() 方法来实现的,下面我们就来一次认识下。(贴出中文注释,便于复制)


final Node<K,V>[] resize() {
  Node<K,V>[] oldTab = table;
  // 存储old table 的大小
  int oldCap = (oldTab == null) ? 0 : oldTab.length;
  // 存储扩容阈值
  int oldThr = threshold;
  int newCap, newThr = 0;
  if (oldCap > 0) {
    // 如果old table数据已达最大,那么threshold也被设置成最大
    if (oldCap >= MAXIMUM_CAPACITY) {
      threshold = Integer.MAX_VALUE;
      return oldTab;
    }
    // 左移扩大二倍,
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
      // 扩容成原来二倍
      newThr = oldThr << 1; // double threshold
  }
  // 如果oldThr                                                                                                                                               !> 0
  else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;
  // 如果old table <= 0 并且 存储的阈值 <= 0
  else {               // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
  }
  // 如果扩充阈值为0
  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;
  // 如果第一次进行table 初始化不会走下面的代码
  // 扩容之后需要重新把节点放在新扩容的数组中
  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;
}


扩容机制源码比较长,我们耐心点进行拆分


我们以 if...else if...else 逻辑进行拆分,上面代码主要做了这几个事情


  • 判断 HashMap 中的数组的长度,也就是 (Node<K,V>[])oldTab.length() ,再判断数组的长度是否比最大的的长度也就是 2^30 次幂要大,大的话直接取最大长度,否则利用位运算 <<扩容为原来的两倍


image.png


如果数组长度不大于0 ,再判断扩容阈值 threshold 是否大于 0 ,也就是看有无外部指定的扩容阈值,若有则使用,这里需要说明一下 threshold 何时是 oldThr > 0,因为 oldThr = threshold ,这里其实比较的就是 threshold,因为 HashMap 中的每个构造方法都会调用 HashMap(initCapacity,loadFactor) 这个构造方法,所以如果没有外部指定 initialCapacity,初始容量使用的就是 16,然后根据 this.threshold = tableSizeFor(initialCapacity); 求得 threshold 的值。


image.png


然后会判断 newThr 是否为 0 ,笔者在刚开始研究时发现 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); 一直以为这是常量做乘法,怎么会为 0 ,其实不是这部分的问题,在于上面逻辑判断中的扩容操作,可能会导致位溢出


导致位溢出的示例:oldCap = 2^28 次幂,threshold > 2 的三次方整数次幂。在进入到 float ft = (float)newCap * loadFactor; 这个方法是 2^28 * 2^(3+n) 会直接 > 2^31 次幂,导致全部归零。


「在扩容后需要把节点放在新扩容的数组中,这里也涉及到三个步骤」


  • 循环桶中的每个 Node 节点,判断 Node[i] 是否为空,为空直接返回,不为空则遍历桶数组,并将键值对映射到新的桶数组中。


  • 如果不为空,再判断是否是树形结构,如果是树形结构则按照树形结构进行拆分,拆分方法在 split 方法中。


  • 如果不是树形结构,则遍历链表,并将链表节点按原顺序进行分组。


微信图片_20220414190124.png




相关文章
|
6月前
|
存储 安全 Java
《ArrayList & HashMap 源码类基础面试题》面试官们最喜欢问的ArrayList & HashMap源码类初级问,你都会了?
《ArrayList & HashMap 源码类基础面试题》面试官们最喜欢问的ArrayList & HashMap源码类初级问,你都会了?
41 0
|
7月前
|
存储 算法 Java
耗时3天写完的HashMap万字解析,争取一篇文章讲透它,面试官看了都直点头!
耗时3天写完的HashMap万字解析,争取一篇文章讲透它,面试官看了都直点头!
94 3
|
7月前
|
Java API
面试官上来就让手撕HashMap的7种遍历方式,当场愣住,最后只写出了3种
面试官上来就让手撕HashMap的7种遍历方式,当场愣住,最后只写出了3种
44 1
|
设计模式 算法 Java
面试官:JDK1.8 HashMap扩容rehash算法是如何优化的?
本文跟大家聊一聊一个常见的面试题,那就是JDK1.8 HashMap扩容rehash算法是如何优化的?
这21个刁钻的HashMap面试题,我把阿里面试官吊打了
1:HashMap 的数据结构? A:哈希表结构(链表散列:数组+链表)实现,结合数组和链表的优点。当链表长度超过 8 时,链表转换为红黑树。
|
存储 算法 Java
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!(3)
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!
365 0
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!(3)
|
存储 C++ 索引
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!(2)
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!(2)
|
存储 算法 Java
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!(1)
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!
109 0
彻底理解 HashMap 及 LinkedHashMap,面试官请随便问!(1)
|
存储 机器学习/深度学习 缓存
为什么要用HashMap?这样回答面试官直呼内行【手撕HashMap系列】
为什么要用HashMap?这样回答面试官直呼内行【手撕HashMap系列】
298 0
为什么要用HashMap?这样回答面试官直呼内行【手撕HashMap系列】
什么啊?面试官还在问HashMap了,老知识点了啊
不就是一个hash加一个map嘛,多简单啊? 答:利用key的hashCode重新hash计算出当前对象的元素在数组中的下标,存储到数组里面就行了,底层就是数组嘛! 然后面试官说了句:好的,我知道了,回去听消息吧!