HashMap基本的实现流程之扩容

本文接上篇:
《HashMap最基本的实现流程(源码角度)》

前言

上篇主要说了HashMap的put操作实现的主流程,分析了源码中某些关键步骤之所以那样写的原因;对于一些分支流程没有过多展开,这里就填一下扩容的坑(注意:本文还是基于jdk1.7)

扩容

这里先把涉及到扩容的地方的源码贴一下:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

如上图代码,这是HashMap添加元素的函数,上一篇中我们假设还没到扩容的条件,先走了下面的主流程,这里我们就来看一下分支流程——扩容;

1. 扩容的条件

即什么时候会触发扩容?回答这个问题时这里可能会有一个误区(不怕笑话,我之前就是按误区这么想的),就是当 数组存的元素个数大于阈值的时候 会触发扩容,并且回答时脑海中浮现的比例图是下图:
HashMap基本的实现流程之扩容_第1张图片
其实直到我真的去看了HashMap的源码我才意识到我的错误。上一篇我一开始也特别强调了下size的含义;其实触发扩容的条件应该是这样的:当哈希表中存的元素个数(size:数组+链表存的元素)超过了阈值(threshold)时。

这样就对了嘛?对了一半,看源码中判断的条件,是一个且运算,即后面还有半句,这就表示并不是size>threshold时就会扩容,什么时候size>threshold但还不扩容呢?——当要存的下一个元素的索引并没有冲突时。如下图,假设现在的参数情况如下:

capacity=4(2^2)
loadfactor=0.75
threshold=capacity*loadFactor=3
size=3(满足扩容第一个条件)

存储情况如下图:
HashMap基本的实现流程之扩容_第2张图片
这时要添加的元素的索引被计算出来是3(或者2),因为此时2或者3的位置都没有元素(即table[index]==null),即没有冲突,所以直接放进去,此时这种情况不会触发扩容。

所以,最终触发扩容的条件(其实源码中很清楚):当存储的元素个数超过了设定的阈值并且即将添加的元素发生冲突。
PS:这里之所以展开说,是因为今天恰巧看见有面经里描述有面试官这样问过。不然我也没在意这里(面试官都是魔鬼吗??!peace)。

2.为什么扩容

从字面意思很容易理解为:因为容量不够了。可是我们刚了解到,hashmap的存储结构时数组加链表,数组会存满,但是理论上将值要JVM的空间足够,链表不会满,能一直往下放元素。
这里就要说一下HashMap的get操作了:

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

get方法中的主要部分是getEntry函数,可以看到,其实就是遍历链表,所以虽然链表可以无限存下去,但是将严重影响查询的效率。通过扩容可以使链表变短,因为因发生冲突而被放在同一条链上的entry有可能通过扩容而分在不同链上。

这里说一下hashMap中key为对象时的注意点:

  1. 作为key的对象要重写equals方法:hashmap判断key相等的条件:== 或者 equals 两个有一个为true即可。对于原始类型,== 就可以判断相等;对于引用类型,不同的对象==始终为false,所以只能依赖equals方法。
  2. 作为key的对象要重写hashcode方法:因为hashmap根据key的hashcode计算索引。所以业务上相等的对象其hashcode也要相等
3. 扩容操作

首先从整体上了解扩容的流程:
HashMap基本的实现流程之扩容_第3张图片
可以看到,扩容的实质其实就是复制(我第一次知道这个实质是震惊的,扩容多高大上的词,万万没想到有着这么朴实无华的实现),并且扩容规律是二倍扩容(2*table.length),
这里重新计算index好理解,因为毕竟表的长度变了,但是有一个 疑问①:为什么扩容后要再次hash? key还是原来那个key,其hashcode也不会变啊,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;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

根据代码可以看到,在创建新数组前会有一个判断分支:当hashmap已经扩容到允许的最大值(MAXIMUN_CAPACITY;230)时,阈值就变成Integer.MAX_VALUE(231-1),并直接返回。这意思就很明确了,capacity=230后就不会再扩容了,因为size永远也达不到threshold了。

下面可以看到使用新长度(原长度的2倍,符合2的次幂)new一个新的数组。接下来就是把数组中的元素一个个复制到新数组中(transfer函数)。

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) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

这里可以看到,使用了一个双层循环去遍历哈希表,外层循环遍历数组,内层循环遍历链表,然后将遍历到的元素(entry)是哟个头插法插入到新的数组中。
这里有一个值得注意的点,就是这个函数的入参中有一个布尔变量rehash,通过这个变量来判断是否需要重新计算所复制元素的key的hash值。这个变量的入参是一个函数的返回值;

    final boolean initHashSeedAsNeeded(int capacity) {
        boolean currentAltHashing = hashSeed != 0;
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                ? sun.misc.Hashing.randomHashSeed(this)
                : 0;
        }
        return switching;
    }

这里我们先不具体去了解函数的逻辑及意义,我们先知道一点,这个函数中操作了hashseed,而我们的key的hash也会受这个hashseed 的影响;所以这里就可以解答上面的疑问①了——因为在扩容的操作中调用了initHashSeedAsNeeded函数,而这个函数在特定条件下会影响hashseed的值,而hashSeed的值会影响key 的hash值,所以要重新hash。
解决了疑问①,我们再来看这个initHashSeedAsNeeded函数到底做了什么?以及目的是什么?我这里不展开讲了,可以看下以下两个文章:
文章1
文章2
这里直接给出结论:hashseed以这个函数设计的目的就是可以让用户去影响HashMap 中当key类型为String类型的hash方法,以期望达到降低碰撞的目的。而我们一般情况下hashseed的值一直是为0的,这个函数的返回一直是false的。

回到transfer函数,rehash=false,下面就是计算索引,头插法插入元素了。
看一下索引的计算:
我们把第一篇文章中的例子先拿过来

0101 0011(h)
&0000 1111(length-1=15)
=0000 0011(3)

这里hash之不变,length变为2倍,重新计算索引:

0101 0011(h)
&0001 1111(length-1=31)
=0001 0011(19)

认真的同学可能已经发现了,相比于扩容前,与运算的结果就是倒数第五位从0变为1,对应到十进制刚好就是原来的值加一个扩容的长度的值;

newIndex=oldindex+oldCapacity

要得出这个结论还有一个条件——原hash值的倒是第五位为1,如果是0,则新的索引和老的所以保持一致;
利用这个这个结论,就不需要再通过与运算计算索引了,可以直接根据hash值那一位(newLength-1的二进制第一个1那一位)判断索引是否改变(增加一个oldCapacity的长度).【jdk1.7并没有用到这一特性,1.8用到了】

到这里扩容就结束了。

可以看到HashMap的扩容效率还是很低的,这也是为什么编码规范会提示你在创建HashMap的时候最好赋一个容量大小。因为用户根据实际需求赋值的容量,可以减少甚至避免(如果用户完全知道自己的Map要存多少东西)扩容操作,从而提高效率。

PS:之前HashMap这种官方的,我们拿来就用的东西,一直对我来说很神秘,很高大上,感觉遥不可及,他肯定使用了什么了不得的东西,可是当你看下去的时候,你会发现,原来就这啊,不就是复制嘛?他原来也是会使用双层循环的啊。当你再仔细看的时候,就会再次发现,他的那些精巧的构思是多么妙不可言!

以上

你可能感兴趣的:(源码,hashmap,java)