面试高频问题:HashMap实现原理

Posted Java这点事

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试高频问题:HashMap实现原理相关的知识,希望对你有一定的参考价值。

Java这点事

互联网/编程/Java/职场面试

关注


今天给同学们讲讲一个面试经常遇到的高频问题,HashMap实现原理,希望在金三银四的季节对同学们有帮助。面试高频问题:HashMap实现原理

HashMap结构图


目录

  • 唠叨

  • 解析思路

  • get方法

  • put方法

  • resize方法


唠叨

认真阅读了下HashMap的实现方式,也参考了网上别人的一些解析,个人觉得还是有些东西想说。网上有的文章名字为HashMap源码解析,实际上就是给它里面的一些方法加上一些注释而已,有不少都是这样的。

我自己看源码的时候,发现不是别人不想解析,而是它的实现真的需要亲自研读,多理顺几遍才知道怎么回事。

我在这里解析的文字描述也较多,不管谁的解析,自己也都要看一下JDK源码的具体实现,我们仅提供参考而已。


解析思路

源码不太方便看,先说明一下我的阅读思路。

  1. 把常用的几个方法拷贝到文本编辑器里面。

  2. HashMap中不同的时候会有不同的流程,梳理方法中的逻辑流程。
    就像采用极端法,采用特殊的数据,然后查看方法执行语句。未执行的语句暂时不考虑。

  3. 注释源码...

我觉得HashMap的实现方式不够好,关键的几个方法里面包含的情况太多了,阅读起来是有难度的,而写程序的目的之一不就是让其他开发者阅读吗?一个方法内部做了太多的事情,违反了代码整洁的规则,一个函数做要尽量少的事情。

解析

之前稍微介绍了一些HashMap的特性,HashMap初探。(https://www.jianshu.com/p/be9ffb76db30)

这里接着深入。


get方法

先挑最简单的说...

javapublic V get(Object key) {Node
 
   
   
 
  
    
    
   e;return (e = getNode(hash(key), key)) == null ? null : e.value;}
 
   
   
 
  1. 先从数组下标,找到对应的Node

  2. 如果Node里的第一个节点命中,直接返回

  3. 如果有冲突,则通过key.equals(k)去查找对应的entry

  4. 若为树,则在树中通过key.equals(k)查找,O(logn);

  5. 若为链表,则在链表中通过key.equals(k)查找,O(n)。

// hash值为hash(key),keyfinal Node
 
   
   
 
  
    
    
   getNode(int hash, Object key) {        Node
  
    
    
  
    [] tab; Node 
   
      first, e; int n; K k;        //table不为空,并且tab[(n-1) & hash] != null的时候。        if ((tab = table) != null && (n = tab.length) > 0 &&            (first = tab[(n - 1) & hash]) != null) {                        //判断取出Node的hash值是否相等。key值相等,那么直接返回。            //想一想什么情况下,if语句不成立?            if (first.hash == hash && // always check first node                ((k = first.key) == key || (key != null && key.equals(k))))                return first;                        //也就是取出的第一个Node的hash值与key计算的hash不等。                if ((e = first.next) != null) {                if (first instanceof TreeNode)                                    //从树中取节点。                    return ((TreeNode 
    
      )first).getTreeNode(hash, key);                                    do {                    //判断hash值与key值是否相等,一直判断到相等或到节点末端为止。                    if (e.hash == hash &&                        ((k = e.key) == key || (key != null && key.equals(k))))                        return e;                } while ((e = e.next) != null);            }        }        return null;    } 
     
    
  
 
   
   
 


put方法

这个中间涉及的逻辑多一些,方法需要分不同的步骤看。
思路:

  • 对key的hashCode()做hash,然后再计算index;

  • 如果没碰撞直接放到bucket里;

  • 如果碰撞了,以链表的形式存在buckets后;

    • 如果节点已经存在就替换old value(保证key的唯一性)

    • 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;

  • 如果Node的容量满了(超过load factor*current capacity),就要resize。

 public V put(K key, V value) {        return putVal(hash(key), key, value, false, true);    }  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,                   boolean evict) {        Node
 
   
   
 
  
    
    
  [] tab; Node
  
    
    
  
     p; int n, i;                //如果table为空,就重新创建table        if ((tab = table) == null || (n = tab.length) == 0)            n = (tab = resize()).length;        // 如果tab[(n-1) & hash]为空的话,就在tab[(n-1) & hash]位置存储节点。        // newNode = new Node<>(hash, key, value, next);            if ((p = tab[i = (n - 1) & hash]) == null)            tab[i] = newNode(hash, key, value, null);        else {                    //当tab[(n-1)&hash]位置已经存在Node的时候。            Node 
   
      e; K k;             //如果已经存在的Node与即将要存的key值一样            // e为存在的Node            if (p.hash == hash &&                ((k = p.key) == key || (key != null && key.equals(k))))                e = p;            else if (p instanceof TreeNode)                e = ((TreeNode 
    
      )p).putTreeVal(this, tab, hash, key, value);            else {                //不满足以上的情况,一直把Node往后插入。                //如果插入的节点数量多于TREEIFY_THRESHOLD-1个,变为树形节点                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值相同的时候,就跳出循环。e = p.next,                    // 这时已经记录e的Node值了                    if (e.hash == hash &&                        ((k = e.key) == key || (key != null && key.equals(k))))                        break;                    p = e;                }            }                        // 存在对应的Node时            if (e != null) { // existing mapping for key                V oldValue = e.value;                if (!onlyIfAbsent || oldValue == null)                    e.value = value;                afterNodeAccess(e);                return oldValue;            }        }                // HashMap内部修改的初始        ++modCount;                //如果存储的节点数,大于临界值,重新分配大小        if (++size > threshold)            resize();         //抽象方法,当节点执行插入操作的时候如何处理            afterNodeInsertion(evict);        return null;    } 
     
    
  
 
   
   
 

一般不发生碰撞的时候,相对简单,数据量较小的情况下。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node[] tab; Node p; int n, i;if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;    // 留意 i = (n-1)&hash,所以取的时候也这样取  // newNode = new Node<>(hash, key, value, next);    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null);    ++modCount;    if (++size > threshold)        resize();    afterNodeInsertion(evict);    return null;}

发生碰撞后,有个红黑树的处理,因为红黑树相对知识点较多,下次单独详细解释。

这里可以参考以下,从JDK源码研究红黑树。(https://link.jianshu.com/?t=https%3A%2F%2Fwww.ibm.com%2Fdeveloperworks%2Fcn%2Fjava%2Fj-lo-tree%2Findex.html

我解释下关于碰撞冲的循环。

  • 查看是否存在相同的key,存在相同的key跳出循环,覆盖key的value

  • 如果不存在相同的key,在链表末尾插入新的Node

  • 如果链表节点过长,转换为树。

            for (int binCount = 0; ; ++binCount) {                // p.next为null的时候,走到了链表的末端,然后新建一个节点,如果链表的                长度太长,转换为树存储。                if ((e = p.next) == null) {                    p.next = newNode(hash, key, value, null);                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st                        treeifyBin(tab, hash);                    break;                }                              // 如果链表中存在于要put的key值相同的时候,存储key值,也就是e ,(e = p.next)。                if (e.hash == hash &&                    ((k = e.key) == key || (key != null && key.equals(k))))                    break;                p = e;            }

红黑树的部分,我们下次单独解析


resize方法

这个涉及的内容,有不少线需要捋一捋。首先看申明时候会resize()。它们都在调用put的时候执行的。

  • table == null的时候

if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;
  • 键值映射的的数目大于临界值的时候。

if (++size > threshold)resize();

resize具体方法
final Node
 
   
   
 
  
    
    
  [] resize() {        Node
  
    
    
  
    [] oldTab = table;                // 之前的容量可能为0或者为之前的大小        // threshold可能为null或者为2的n次方        int oldCap = (oldTab == null) ? 0 : oldTab.length;        int oldThr = threshold;         //新的容量,新的临界目前都为0        int newCap, newThr = 0;         // 第二次resize的时候。        if (oldCap > 0) {            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        }        //第一次resize()的时候,初始化的操作。        else if (oldThr > 0) // initial capacity was placed in threshold            newCap = oldThr;        else {               // zero initial threshold signifies using defaults            newCap = DEFAULT_INITIAL_CAPACITY;            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);        }                //第一次resize(),会进入        if (newThr == 0) {            //负载因子 * 初始容量            float ft = (float)newCap * loadFactor;                        //保证临界值不超过最大值。            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?                      (int)ft : Integer.MAX_VALUE);        }                //真正初始化的操作,新建newCap个数组,临界值初始化。        threshold = newThr;        @SuppressWarnings({"rawtypes","unchecked"})            Node 
   
     [] newTab = (Node 
    
      [])new Node[newCap];        table = newTab;                // 非 第一次reizie()时        if (oldTab != null) {            for (int j = 0; j < oldCap; ++j) {                Node 
     
        e;                if ((e = oldTab[j]) != null) {                                        // 重新计算了一次hash                    oldTab[j] = null;                    if (e.next == null)                        newTab[e.hash & (newCap - 1)] = e;                                            else if (e instanceof TreeNode)                        ((TreeNode 
      
        )e).split(this, newTab, j, oldCap);                    // 如果e.next != null 存在hash的Node 链子                    else { // preserve order                        Node 
       
          loHead = null, loTail = null;                        Node 
        
           hiHead = null, hiTail = null;                        Node 
         
            next;                                                do {                            next = e.next;                            // 原索引                            if ((e.hash & oldCap) == 0) {                                if (loTail == null)                                    loHead = e;                                else                                    loTail.next = e;                                loTail = e;                            }                            // 原索引+ oldCap                            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;    } 
          
         
        
       
      
     
    
  
 
   
   
 

如果是第一次resize,我们抽出来会执行到的语句。

  • 初始化容量

  • 初始化threshold,也就是初始化临界值,决定了table的键值对数目到什么时候会再次resize()

final Node[] resize() {//第一次的时候table为nullNode[] oldTab = table;// oldCap 为 0,threshod为nullint oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;    int newCap, newThr = 0;// 不会走if (oldCap > 0) {....// 从这里执行else { // zero initial threshold signifies using defaultsnewCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}    threshold = newThr;    @SuppressWarnings({"rawtypes","unchecked"})        Node
 
   
   
 
  
    
    
  [] newTab = (Node
  
    
    
  
    [])new Node[newCap];    table = newTab;    return newTab;} 
  
 
   
   
 

第二次及后续的resize执行流程

final Node[] resize() {Node[] oldTab = table;// oldCap为当前table的长度, oldThr为上次的table临界值int oldCap = (oldTab == null) ? 0 : oldTab.length;int oldThr = threshold;    int newCap, newThr = 0;    if (oldCap > 0) {        // 这个if语句保证容量不超过hashmap的容量上限值。        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    }     threshold = newThr;    @SuppressWarnings({"rawtypes","unchecked"})        Node
 
   
   
 
  
    
    
  [] newTab = (Node
  
    
    
  
    [])new Node[newCap];    table = newTab;     //第二次扩容的时候,对上次的table如何处理。    if (oldTab != null) {         // 遍历之前的table,重新hash排序        for (int j = 0; j < oldCap; ++j) {            Node 
   
      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 
    
      )e).split(this, newTab, j, oldCap);                 //链表的操作                else { // preserve order                    Node 
     
        loHead = null, loTail = null;                    Node 
      
         hiHead = null, hiTail = null;                    Node 
       
          next;                    do {                        next = e.next;                        // oldCap 为 16也就是 10000,                        //oldCap为16的倍数,这里是hash值为低数字的时候                         if ((e.hash & oldCap) == 0) {                            //第一次                            if (loTail == null)                                loHead = e;                            //计算新的next                            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;} 
        
       
      
     
    
  
 
   
   
 

resize中对有碰撞的链表的操作写的很有意思,再叙述一下。在重新分配索引的时候,有重新组建链表的操作。

举个比较夸张的例子,读者就明白了。

  • e.hash < 2,那么 e.hash&oldCap就等于0,索引为小于之前hash表大小以内的索引。也就是当初的索引不变。

  • e.hash > 2的时候,e.hash&old不等于0,那么它的索引就为当前表的索引再加上新扩容的大小。

案例图


这个图说的是,当hashmap的表大小为2扩充到4的时候,原本挂载在1位置的链表,重新分配之后的样子。

                    Node
 
   
   
 
  
    
    
   loHead = null, loTail = null;                    Node
  
    
    
  
     hiHead = null, hiTail = null;                    Node 
   
      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;                    } 
    
  
 
   
   
 

最后

篇幅有限,我这里仅仅介绍了get方法,put方法,resize方法的具体原理,文章就已经非常长了,不利于阅读。
下次再补充一下HashMap的hash方法原理,其余的相关注意事项。


头图来源:视觉中国



好玩,漫画,面试经验等欢迎投稿:luckyjing007@qq.com

一经使用,我们将按文章质量给予稿费

扫码上方二维码关注


以上是关于面试高频问题:HashMap实现原理的主要内容,如果未能解决你的问题,请参考以下文章

Java高薪面试宝典Day3图解HashMap高频面试及底层实现架构!

聊聊面试中的 Java 线程池

源代码系列01——HashMap源码分析

Java后端面试高频问题:HashMap的底层原理

为啥面试要问hashmap 的原理

大厂面试必问!HashMap 怎样解决hash冲突?