通俗易懂而不失深度 HashMap解读

什么是HashMap

HashMap在代码的定义如下,从代码可以看出 HashMap的结构是一个数组。

Node<K,V>[] table;
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
		//省略实现
    }

其结构如下图所示: haspMap的结构由数组、链表、红黑树组成。
在这里插入图片描述

什么选择这样的结构呢?

我们先整理下每一种构的特点。
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

可以得出结论,数组(使用下标时)效率是最高的。使用数组我们需要解决以下问题?

1: key-value 中的key不是数组的下标,我们需要把key转成下标,例如,0,1,2,3,4。

方案:把Key进行Hash转换获取数字 并取 & (n-1),满足取出来的值 小于数组的长度。

2: key转成hash,那就必须会遇到hash冲突的问题。

方案:数据的Value存的是链表,把hash值一样的数据插入到链表的未端。

3: 生成的下标需要小于数组的长度,并组分散。

方案:数组的长度(n)设置为2的幂,(n-1)取 与(&)计算,可以获得小于数组长度,并组分散在0~n中。

4: 链表长度过长,查询性能差

方案:链表长度过长时,把结构转成换红黑树。

5: 数据过多时,而数组的长度不够,造成的Hash冲突严重,影响性能。

方案:数组扩容,减少hash冲突。

原代码分析

阈值

主要是HashMap的默认值设置,详细情况见注释。


     /**
     * 默认的初始化变量(16)-必须是2的幂(次方)
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    /**
     * 最大容量(必须是2的幂且 <= 2的30次方)
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;
    /**
     * 默认的装载因子(填充比例):0.75,即实际元素所占所分配的容量的75%时就要扩容啦。
     * 如果该值过大,再增加元素将导致链表过长,查找效率降低;反之,浪费空间
     * 关注内存:适当增大填充比;
     * 关注查找性能: 适当减小填充比;
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    /**
     * 每个桶的可树化的阀值,即当桶中元素个数超过该值时,自动将链表转化为红黑树结构
     */
    static final int TREEIFY_THRESHOLD = 8;
    /**
     * 每个桶的去树化阀值,即在扩容(resize())时,发现桶中的元素少于该值,则将树形结构还原为链表结构
     */
    static final int UNTREEIFY_THRESHOLD = 6;
    /**
     * 哈希表中可被树形化的最小数量。
     * 即若哈希表中的元素个数少于该值,则桶(bin)不会被树形化,而是会进行扩容(就算桶中的元素个数超过TREEIFY_THRESHOLD)。
     * 主要是为了避免扩容&可树化的选择冲突
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
 

插入

代码如下,关键的地方增加了注释。

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 桶数组的初始化工作;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
 
        // 如果 table在(n-1)&hash 的值如果为null,则新建一个节点插入到该位置;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 这里表示出现哈希冲突,下标一样。处理冲突的操作
        else {
            Node<K,V> e; 
            K k;
           // 1. 首先判断链表的第一个元素,是不是要替换的值;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 2. 判断 第一个节点的类型是链表or红黑树,对应进行相应的处理。处理节点是红黑树的情况。
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 3. 处理节点类型是链表的情况;
            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;
                    }
                    // 链表中已经存在该节点,直接退出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 若节点已经存在,新值覆盖旧值,同时返回旧值;
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 判断是否需要resize()。
        if (++size > threshold)
            resize();
        // 这行代码是为了LinkedHashMap使用的。
        afterNodeInsertion(evict);
        return null;
    }

插入是HashMap比较难懂的,逻辑比较复杂,是面试的重灾区。插入的逻辑如下图所示。

1、先判容器是否为空,是执行Resize()。
2、 取得key的下标。
1)下标不存在则创建新节点。
2) 下标存在,则判断节点结构
1)) 树结构:增加树结点
2)) 链表结构:如果链表结点>8,则把链表结构转成树,增加链表节点。

3、size++
4、size >= 阈值时 Resize().
在这里插入图片描述

删除

源代码如下

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
     /*根据下标,key值 找到对应的节点 */
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
       
       //如果 hash code一致 并且 值一致 
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
       
            // p有下个节点      
            else if ((e = p.next) != null) {
          
          //树结构时 获取到对应的树结点
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                //获取链表结点
          else {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
       
       /*节点删除,根据链表或者树结构,进行结点删除,其中是树结构时可能触发树转链表*/
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
               
          //树结点删除 ,removeTreeNode 删除动可能触发树转链表的动作 
          if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                else if (node == p)
                    tab[index] = node.next;
                else
           //链表删除 
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);
                return node;
            }
        }
        return null;
    }

删除动作流程如下图所示:

1、取得下标
2、找到数组节点。
3、判断节点的结构类型:
1)链表:遍历值,删除对应的链表节点。
2)树:删除对应的树节点。并判断树的节点数A。如A<=6,则把结构转换成链表。

在这里插入图片描述

查询

源代码如下:

 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) {
        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) {
            if (first.hash == hash && // 第一个节点就是要找的值
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
     
         //第一个不是要找到值,根据不同结构进行查找,返回节点
            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);
            }
        }
     //没找到值,返回null
        return null;
    }

1、根据下标取得数组节点。
2、第一节点的值==key,直接返回第一个节点。
3、第一节点值 !=key。 根据不同结构进行查找,返回节点
4、如查找不到,直接返回null。

关键

下标的算法

怎么样的算法,才能符合数组的要求呢?
第一:必须是数字。
第二:必须<数组的长度-1;
第三:key分散。

下面,我把源代码抽出来,如下:

  	int i=(n-1) & hash ; //n是数组的长度
  	static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

我们先来做个实验,用这个算法,输出1~200的值 对应的下标。代码如下:

public   class HashMapTest {
    
    public  static void main (String[] args)
    {
        int n=128; //数组的容量,必须是2的幂 1<<7

        for(int i=0;i<=200;i++)
        {
            System.out.print(((n-1) & hash(i))+",");
        }
    }
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
}

结果如下:

0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,

从结果来看是符合我们的要求的。下标算法是大厂面试的重点,接下来,我们再深入了解。 下标取得的流程如下:在这里插入图片描述

1:为什么要取 i=(n-1) & hash 。
解: hashMap的数组长度(n)一定保持2的次幂,那么n-1 的二进度数所有的位数必然是1,如下图所示。
那么(n-1)& hash(i) 取出来的下标必然是小于数组个数,并且能覆盖到整个数组的下标。
在这里插入图片描述
下面引用了一个网友的解释:
数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:
在这里插入图片描述
 我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。
 在这里插入图片描述
 如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

写在最后

HashMap真的很有意思,小小的一个东西却充满了学问。感觉学它不应只是为了应对面试,它的的原理和思维方式可以用到其它很多场景。例如:多个结构并存、结构的升级与降级、下标的算法、扩容等等。
另外深入了解了,面试还考得倒你吗?

本人能力有限,出有错误,请指教。
参考:
https://www.cnblogs.com/chengxiao/p/6059914.html#t2
https://blog.csdn.net/wangcheeng/article/details/105294388
java 8 原代码

发布了31 篇原创文章 · 获赞 88 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/richyliu44/article/details/105398192