简谈 HashMap 扩容过程

版权声明:本文章原创于 RamboPan ,未经允许,请勿转载。

简谈 HashMap 扩容过程

  • 第一次 2018年12月02日
  • 第一次 2019年03月09日
    • 调整代码块
    • 变量、类名、方法 标记统一

无意看到了 HashMap 的扩容机制,想了挺久,才逐渐明白,感觉很精髓。
简单分享下自己理解的扩容过程。源码版本为 Android 28 JDK 1.8

先简单说说 HashMap 结构,大家都知道它的内部存储结构是由一个数组进行散列存储。

如果把 n 个对象放入 n + 1 个格子里,就算 n 个平均散列了,第 n + 1 个肯定会放入 n 个格子中一个。

那既然会出现冲突的情况,肯定需要解决。 HashMap 采用的是链表的方式。


代码【1】

    static class HashMapEntry<K, V> implements Entry<K, V> {
    	//键
        final K key;
        //值
        V value;
        //散列值
        final int hash;
        //下一个节点的引用
        HashMapEntry<K, V> next;
     }
     

HashMapEntry 成员变量能看出,存了下一个 HashMapEntry 的引用,没有看到其他的逻辑。

既然这样,那就先来看看它是怎样扩容的。doubleCapacity() 中是怎么处理的。


代码【2】

	private HashMapEntry<K, V>[] doubleCapacity() {
        //新建一个关于旧数组的引用
        HashMapEntry<K, V>[] oldTable = table;
        //获取旧数组的长度
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            return oldTable;
        }
        //两倍扩容
        int newCapacity = oldCapacity * 2;
        //生成新数组
        HashMapEntry<K, V>[] newTable = makeTable(newCapacity);
        if (size == 0) {
            return newTable;
        }

        //大佬说这是最优雅的地方,我们来瞅瞅。
        for (int j = 0; j < oldCapacity; j++) {
            /*
             * Rehash the bucket using the minimum number of field writes.
             * This is the most subtle and delicate code in the class.
             */
             //从旧数组的 0 位置开始查找,空则不考虑。
            HashMapEntry<K, V> e = oldTable[j];
            if (e == null) {
                continue;
            }
            //每个节点的哈希值与旧容量按位进行运算,得到高位。
            //e.hash 代码【3】 highBit //代码【6】
            int highBit = e.hash & oldCapacity;
            HashMapEntry<K, V> broken = null;
            //根据高位与第 j 个位置进行或计算,得到新数组位置。
            newTable[j | highBit] = e;
            //查找该节点的下一个 HashMapEntry,并且判断在新数组哪个位置。
            for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
                int nextHighBit = n.hash & oldCapacity;
                if (nextHighBit != highBit) {
                    if (broken == null)
                        newTable[j | nextHighBit] = n;
                    else
                        broken.next = n;
                    broken = e;
                    highBit = nextHighBit;
                }
            }
            if (broken != null)
                broken.next = null;
        }
        return newTable;
    }

首先说说 代码【3】处,e.hash 是什么。

看看 HashMapEntry 的构造函数。


代码【3】

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

构造函数只有一个,hash 值是在初始化时传递的。

那么我们就想想什么时候初始化,那应该就是放数据的时候,肯定是发生在加入新的节点时。


代码【4】

    public V put(K key, V value) {
        ……
        //看名字仿佛是散列过两次。
        int hash = Collections.secondaryHash(key);
        HashMapEntry<K, V>[] tab = table;
        int index = hash & (tab.length - 1);
        for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) {
            if (e.hash == hash && key.equals(e.key)) {
                preModify(e);
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }

        modCount++;
        if (size++ > threshold) {
            tab = doubleCapacity();
            index = hash & (tab.length - 1);
        }
        //能看出来是从这添加一个新的节点。
        addNewEntry(key, value, hash, index);
        return null;
    }
    
    void addNewEntry(K key, V value, int hash, int index) {
        table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]);
    }

果然,就是在这里进行的初始化,就是使用传入的 hash 值,这个 hash 值又是在 secondaryHash() 中得到的。

那么我们去看看 secondaryHash();


代码【5】

    public static int secondaryHash(Object key) {
        return secondaryHash(key.hashCode());
    }
    
    //第一次 key.hashCode() ,是使用 Object 中的 hashCode();
    //作用是针对键进行散列,使得算出来的 hashCode 更唯一性。
    public int hashCode() {
        int lockWord = shadow$_monitor_;
        final int lockWordMask = 0xC0000000;  // Top 2 bits.
        final int lockWordStateHash = 0x80000000;  // Top 2 bits are value 2 (kStateHash).
        if ((lockWord & lockWordMask) == lockWordStateHash) {
            return lockWord & ~lockWordMask;
        }
        return System.identityHashCode(this);
    }
    
    //第二次的 secondaryHash(),是使得对散列过的键值,更均匀的散列在散列表中。
    //进行了大量的 移位 与 异或计算,移位使得 高位 与 低位 能更合理的对散列均匀。
    private static int secondaryHash(int h) {
        h += (h <<  15) ^ 0xffffcd7d;
        h ^= (h >>> 10);
        h += (h <<   3);
        h ^= (h >>>  6);
        h += (h <<   2) + (h << 14);
        return h ^ (h >>> 16);
    }

从上面的分析可以看出,最后进行初始化的 HashMapEntry 中的 hash 值,就是二次散列后的结果。

那么放在散列中的哪个位置呢,就是对应的 indexindex = (table.length - 1) & hash 进行计算得出的。

那么简单画个示意图说明下,只分析了低 8 位的情况。

那么存放新节点基本上就这样了,没有分析如果存放出现冲突的情况,不是本文分析的重点。

下面接着 代码【2】中下半部分继续说,即代码【6】。


代码【6】

	for (int j = 0; j < oldCapacity; j++) {
	    HashMapEntry<K, V> e = oldTable[j];
	    if (e == null) {
	        continue;
	    }
	    int highBit = e.hash & oldCapacity;//highBit 代码【6】
	    HashMapEntry<K, V> broken = null;
	    newTable[j | highBit] = e;
	    for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
	        int nextHighBit = n.hash & oldCapacity;
	        if (nextHighBit != highBit) {
	            if (broken == null)
	                newTable[j | nextHighBit] = n;//代码【8】
	            else
	                broken.next = n;//代码【10】
	            broken = e;
	            highBit = nextHighBit;
	        }
	    }
	    if (broken != null)
	    broken.next = null;
	}

0 开始从旧数组中读取数据,先说说 highBit,高位与旧容量进行与计算,因为容量都是 2 的指数,以 8 为例子。

假设旧数组中第一个节点中的 hash 值为 11110000,与 8 - 1 进行 & 计算时为 000 进行 | 运算,所以 index0。新数组中还是 0 ,位置没变。

newTable[ j | highBit ] —> newTable[ 0 | 0] —> newTable[0]

不过如果 第一个节点中的 hash 值为 11111000,那么此时的 highBit 就为 1 换为 10 进制就是 81 再与 8 进行 | 运算,就为 9 了。

newTable[ j | highBit ] —> newTable[ 1 | 8 ] —> newTable[9]

此时发现位置不一样了是吧。不过光看代码不能感觉什么,如果能想下实图的话那就明白了。

那上个图来说明下,我们只分析了两种情况,其他节点类似,循环的最后一个节点进行判断后结束。

忘了说明:新数组的 此时 index 计算 是当它再次扩容时需要计算时 table.length - 1 = 15
所以就是后四位。这次扩容是 oldTable.length - 1 = 7 ,所以是后三位。

是不是发现扩容其实更均匀的把以前一个格子的节点,再分散的新的数组。是不是很机智。

那么肯定要说了,这只是简单地说了,如果每个格子只有一个节点,那么有多个节点呢,是单链表结构呢 ?

那我们继续分析 if (broken == null) if (broken != null) 的情况。


代码【7】

    HashMapEntry<K, V> broken = null;
    newTable[j | highBit] = e;
    for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
        //查找下一个节点
        int nextHighBit = n.hash & oldCapacity;
        //判断该节点高位,如果高位与之前相同,那么查找下一个。
        //如果该节点高位不同,则判断 断点是否为null。
        if (nextHighBit != highBit) {
            //如果为null,则为第一次,需要移动的另外的格子(类似上图中,旧数组:1 -> 新数组:9)
            if (broken == null)
                newTable[j | nextHighBit] = n;
            //如果不为null,则证明之前已经移动过,那么此时需要移动的,就在之前的断点之后继续添加。    
            else
                broken.next = n;
            //重置断点位置与高位。
            broken = e;
            highBit = nextHighBit;
        }
    }
    //最后判断断点是否为 null,为 null 时将下一个 HashMapEntry 引用置为 null。
    if (broken != null)
        broken.next = null;

主要问题: broken 为什么会切换 ?怎么切换 ?切换之后又是怎样保证在新的位置上形成链表 ?

有时可以先根据结果猜想怎么的条件触发,再反推原理,这样比较好设计的初衷。

只分析一个节点,其他节点类似。假设 oldTable[0] 处节点,单链表长度为 41111 0 000(因为此时只需要关心低4位,所以我间隔开了)。
在这里插入图片描述

对应代码:


	for (int j = 0; j < oldCapacity; j++) {
		……
		// 根据之前的分析,高位为 0 
		int highBit = e.hash & oldCapacity;
		// j 为 0 , newTable[ 0 | 0 ]  = newTable[0] 
		newTable[j | highBit] = e;
		……
	}    


代码【8】

在这里插入图片描述
if (broken == null) 时: 对应代码:


	HashMapEntry<K, V> broken = null;
	//判断下一个节点是否为 null ,此时不为 null ,对第二个节点 1111 1 000 进行判断。
	for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
		//计算 nextHighBit 
		int nextHighBit = n.hash & oldCapacity;
		//上一步 highBit = 0 ,nextHighBit = 1 , broken == null,满足判断。
		if (nextHighBit != highBit) {
			if (broken == null)
				//此时 newTable [ 0 | 8]  = newTable[8]
				newTable[ j | nextHighBit ] = n;
			else
				broken.next = n;
			// broken  指向第一个节点 1111 0 000
			broken = e;
			//高位改为 1
			highBit = nextHighBit;
		}
	}
	//再循环判断没有下一个,退出循环,将 第三个节点 .next 置为 null。
	//如果 broken == null ,说明单链表在移动时都移动到新数组同一个位置,所以 断点 为 null ,也不需要置 null。
	 if (broken != null)
                broken.next = null;


代码【9】

在这里插入图片描述
对应代码:

	
	//判断下一个节点是否为 null ,此时不为 null ,对第三个节点 1010 1 000 进行判断。
	for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
		//计算高位
		int nextHighBit = n.hash & oldCapacity;
		//此时 highBit 为 1 , nextHighBit 也为 1 ,说明不处理,直接跳过,判断第四个节点。
		if (nextHighBit != highBit) {
			……
		}
	}


代码【10】

在这里插入图片描述
if (broken != null) 时: 对应代码:


	//对第四个节点 1011 0 000 进行判断。
	for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
		//计算 nextHighBit = 0
		int nextHighBit = n.hash & oldCapacity;
		//上一步 highBit = 1 ,nextHighBit = 0 , broken != null,满足判断。
		if (nextHighBit != highBit) {
			if (broken == null)
				newTable[ j | nextHighBit ] = n;
			else
				// broekn 为第一个节点。
				broken.next = n;
			// broken  指向第三个节点
			broken = e;
			// highBit 改为 0
			highBit = nextHighBit;
		}
	}


broken 的意义,就是存储某一边末端节点的引用:

  • 比如我现在在 index = 8 的位置,下一个节点需要重新接回 index = 0 处。
  • 那么 broken 就保存之前的那个 index = 0 的最后节点。
  • 然后把新节点接在 broken 之后,然后再将 broken 指向 index = 8 的末端节点。

以上分析仅供参考,如果有错误,希望指出,共同进步

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值