HashMap原理分析总结

HashMap即哈希表,是面试时最常见的一类问题,也是应用非常广泛的一种集合,现在将HashMap的学习总结一下。
先来看HashMap中几个很重要的变量:

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    static final int TREEIFY_THRESHOLD = 8;

其中,DEFAULT_INITIAL_CAPACITY为HashMap的初始容量,默认为16;
MAXIMUM_CAPACITY为HashMap的最大容量,为2^30;
DEFAULT_LOAD_FACTOR为负载因子,默认为0.75,负载因子是用来衡量HashMap中元素数量与HashMap容量的比值大小的,换句话说,负载因子乘以HashMap容量即是扩容阈值,如果HashMap中的元素数量达到了这个扩容阈值,那么HashMap就会进行扩容,后面会进行分析;
TREEIFY_THRESHOLD为链表重构为红黑树阈值,默认为8。

1.HashMap的结构
HashMap的数据结构在jdk 1.7及以前是简单的数组+链表结构,在这种结构下,如果数组某一index下的链表过长,则会导致数据存取的时间复杂度达到O(N),为此,jdk 1.8对其进行了优化,定义了阈值TREEIFY_THRESHOLD = 8,当链表长度达到该阈值时,链表则会重构为红黑树,在红黑树数据结构下,数据存取的时间复杂度为O(logN),因此,HashMap的数据结构在jdk 1.8后为数组+链表+红黑树,如下图所示。
在这里插入图片描述
其中,数组的每个元素均是Node型的,需要注意的是,在jdk 1.8之前每个元素的定义为Entry,在jdk1.8中改为了Node,实际上二者区别不大,Node的定义如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
......
}

可以看到,每一个Node中包含了相应的键值对信息、hash值以及指向下一个结点的指针,即相当于各链表的头结点。

2.put原理
put函数用于向HashMap中添加键值对,在添加时,首先需要通过hash函数获取待插入键值对的key值所对应的hash值,然后将该hash值与数组长度取余最终得到key在HashMap中对应的index,然后再访问index,遍历index下的所有结点,如果key值存在那么就用新的value进行覆盖,否则将键值对插入链表中,源码如下:

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;   //初始化桶,默认16个元素
    if ((p = tab[i = (n - 1) & hash]) == null)   //如果第i个桶为空,创建Node实例
        tab[i] = newNode(hash, key, value, null);
    else { //哈希碰撞的情况, 即(n-1)&hash相等
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;   //key相同,后面会覆盖value
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  //红黑树添加当前node
        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);  //当链表个数大于等于7时,将链表改造为红黑树
                    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;            //覆盖key相同的value并return, 即不会执行++size
        }
    }
    ++modCount;
    if (++size > threshold)    //key不相同时,每次插入一条数据自增1. 当size大于threshold时resize
        resize();
    afterNodeInsertion(evict);
    return null;
}

需要注意的是,在这段源码中直接使用if ((p = tab[i = (n - 1) & hash]) == null)一句来找出相应的index,而不是使用indexFor函数(实际上indexFor函数也就是这一句),n为HashMap容量,将n-1再与hash按位与,最终得到的i即是键值对对应的index了。为什么这里i 就等于(n - 1) & hash呢?首先我们需要想到,这里的index映射原则是均匀分配,即是任意一个hash值其所对应的index在这n个桶中的概率是相同的,假设现在的n为默认值16,那么很显然index的范围是0 ~ 15的,15即是16-1,其对应的二进制即是0000 ~ 1111,也就是说,如果我将所有键值对对应的hash值以低四位二进制位进行区分,那么所有hash值必定是等可能的位于0000 ~ 1111之间,这样就实现了均匀分配,那么这种低四位二进制位区分具体是怎么实现呢?

很简单,即是用1111与各hash值进行按位与,这样每个hash值的按位与结果必定在0000 ~ 1111之间,这样也就能够确定其所在的桶了,用n-1与各hash值按位与,所得结果必定也是0 ~ n-1,达到了hash%n的效果(因为hash%n所得结果也是位于0~n-1之间的),而在另一方面,由于取模运算的效率是低于按位与运算的效率的,因此往往用按位与而不是取模,不过使用按位与代替取模的前提是n为2的m次幂,因为如果n-1中某一位为0,那么根据按位与的结果时无法确定hash值在该位是0还是1的,比如说n-1的二进制位101,那么111和101与n-1按位与的结果均是101,这样hash值的低三位为111和101的键值对的index均是5(101),index为7(111)的桶就不再可能放入元素了,这样也就不能达到均匀分配的目的,因此,n-1的二进制位中间不能存在0,即必须保证HashMap的容量为2的m次幂。

这也是非常重要的一点,**一定要保证HashMap的容量为2的m次幂,这样才能使用(n - 1) & hash来代替hash%n,从而即提高效率,也保证各hash值对应的Index均匀分配。**即使在创建HashMap时元素个数并不是2的m次幂,HashMap的容量最终也会取大于元素个数的最小的2的m次幂作为HashMap的容量。比如n=6,那么实际的HashMap容量则为8。

此外,根据这段程序中的 if ((e = p.next) == null) {p.next = newNode(hash, key, value, null); 可以发现,在插入元素的时候是先找到当前index下链表的末端(p.next==null),然后将新元素插入末端后(p.next = newNode(hash, key, value, null))此时新元素的next指针指向Null,由此可以看出插入元素时使用的是尾插法。

需要注意的是,这里尾插法也是在jdk 1.8时进行的改变,在其之前,插入新元素的代码如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);//扩容后重新计算插入的位置下标
        }
        //把元素放入HashMap的桶的对应位置
        createEntry(hash, key, value, bucketIndex);
    }
//创建元素  
    void createEntry(int hash, K key, V value, int bucketIndex) {  
        Entry<K,V> e = table[bucketIndex];  //获取待插入位置元素
        table[bucketIndex] = new Entry<>(hash, key, value, e);//这里执行链接操作,使得新插入的元素指向原有元素。
//这保证了新插入的元素总是在链表的头  
        size++;//元素个数+1  
    }  


`
Entry<K,V> e = table[bucketIndex];

table[bucketIndex] = new Entry<>(hash, key, value, e);`

不难看出,这种插入新元素采取的方法是头插法。那为什么在jdk 1.8会做出改变采用尾插法呢?很明显头插法只需要找到头结点插入即可,而尾插法需要遍历至尾结点再插入,头插法显然是效率更高的,那为什么要采取尾插法呢?原因就在于jdk 1.8为了在链表长度过长时提高元素存取效率,采取了链表重构为红黑树的方法,JDK1.7是用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

3.get原理
get函数相比于put函数更加简单,与put函数相类似,通过key找出相应的index,然后遍历index下的链表各结点,判断各结点的key值与待获取的key值是否相等即可,这里不做赘述。

4.扩容原理
前面提到,当HashMap中的元素数量达到了扩容阈值,那么HashMap就会进行扩容,扩容阈值=负载因子*HashMap容量,HashMap的元素个数是指数组以及链表和树中所有元素的个数之和。由于HashMap的容量必须为2的倍数,那么HashMap扩容后的容量大小即是当前容量大小的两倍,比如说,当前HashMap的容量为16,扩容后的容量即是32。扩容后,如前所述,index = (n - 1) & hash,n变了,那么每个key所对应的index也就变了,那么就需要对HashMap进行rehash,即对所有元素的index进行重定位。jdk1.8 对resize做出了很大的优化,在其之前,rehash代码如下:

void resize(int newCapacity) {  
        Entry[] oldTable = table;//老的数据  
        int oldCapacity = oldTable.length;//获取老的容量值  
        if (oldCapacity == MAXIMUM_CAPACITY) {//老的容量值已经到了最大容量值  
            threshold = Integer.MAX_VALUE;//修改扩容阀值  
            return;  
        }  
        //新的结构  
        Entry[] newTable = new Entry[newCapacity];  
        transfer(newTable, initHashSeedAsNeeded(newCapacity));//将老的表中的数据拷贝到新的结构中  
        table = newTable;//修改HashMap的底层数组  
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阀值  
    }  

void transfer(Entry[] newTable, boolean rehash) {  
        int newCapacity = newTable.length;//容量  
        for (Entry<K,V> e : table) { //遍历所有桶
            while(null != e) {  //遍历桶中所有元素(是一个链表)
                Entry<K,V> next = e.next;  
                if (rehash) {//如果是重新Hash,则需要重新计算hash值  
                    e.hash = null == e.key ? 0 : hash(e.key);  
                }  
                int i = indexFor(e.hash, newCapacity);//定位Hash桶  
                e.next = newTable[i];//元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
                newTable[i] = e;//newTable[i]的值总是最新插入的值
                e = next;//继续下一个元素  
            }  
        }  
    }  

可见,jdk 1.8之前的rehash是需要每个元素重新通过indexFor函数计算Index,然后将元素插入到新数组中相应位置,再来看jdk 1.8的rehash:

final Node<K,V>[] resize() {
         ....

         newThr = oldThr << 1; // double threshold,   大小扩大为2倍
   if (e.next == null)
      newTab[e.hash & (newCap - 1)] = e;  //如果该下标只有一个数据,则散列到当前位置或者高位对应位置(以第一次resize为例,原来在第4个位置,resize后会存储到第4个或者第4+16个位置)
  else if (e instanceof TreeNode)
     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);  //红黑树重构

   else {

     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; //下标位置移动原来容量大小
      }

可以发现,这里采用的方法是判断(e.hash & oldCap) == 0,为什么可以这样判断呢?前面可以知道,以n=16为例,扩容前冲突的各key所对应的hash值的低4位均是相同的,此时扩容后n=32,再进行按位与操作的对象则是低5位了,而第5位要么是0要么是1,如果是0,那么说明该hash值在扩容后的index仍旧不会改变(0xxxx==xxxx),而如果是1,那么说明该hash在扩容后的index会改变,由xxxx变为了1xxxx,index增加量刚好就是扩容前的容量大小。
因此,直接将hash值与原先的容量大小按位与,hash值与16按位与即是测试hash值的倒数第5位是否为0,如果为0,那么Index无变化,否则index就需要加上扩容前的容量大小作为新的index值。

先记录一下,以后再补充…

猜你喜欢

转载自blog.csdn.net/qq_28114615/article/details/85084227