IHAVEAQUESTION - JDK1.7 HashMap 链表头插疑问?

技术博客已迁移至个人页,欢迎查看 yloopdaed.icu

您也可以关注 JPP - 这是一个Java养成计划,需要您的加入。


IHAVEAQUESTION

为什么JDK1.7中HashMap链表插入时要在 遍历完一遍链表 后,再采用头插法?

数组

HashMap在JDK1.7中采用 数组+链表 的存储结构。

数组的角标是在key值hashCode()的基础上进行多次高位移动的扰动后尽量保持散列,代码片段如下:

1 hash

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    h ^= k.hashCode();
    // 多次让高位参与运算,扰动函数
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

2 % -> &

采用更搞笑的 &运算。这里length为数组的长度,源码中巧妙的设计数组的长度必须保持2的整数幂。这样设计才能保证length-1计算后得到 全1 的的二进制序列。

static int indexFor(int h, int length) {
    return h & (length-1);
}

链表

数组的index确认后,就可以将键值对插入相应位置的链表了。

public V put(K key, V value) {
    ... 
    int hash = hash(key); // hash
    int i = indexFor(hash, table.length); // %
    // 判断hashmap中有没有存在相同的key,如果有的话将这个key原来的value覆盖,并返回
    for (Entry e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    ...
    // 扩容,头插
    addEntry(hash, key, value, i);
    return null;
}

上面的源码部分只保留了关键部分

我们都知道JDK1.7中链表的插入方式是头插。头插与尾插相比是节省了一次链表全遍历的时间。直接采用下面代码即可完成:

// 链表头插
table[bucketIndex] = new Entry<>(hash, key, value, table[bucketIndex]);

这部分代码在put方法的addEntry()中,addEntry()方法在链表头插之前做了扩容操作。

但是奇怪的是,在上面put方法中有一段循环遍历链表的代码,这段代码的目的只是检查要插入的Key值是否已经存在在HashMap中,如果存在就修改,同时将原来的值返回。

这我就很疑惑了,为什么这里明明已经遍历过一遍链表了,为什么不多写一个else,如果没有找到存在的Key值,直接将目标键值对插入在链表尾部呢?都已经遍历完了,插个值咋了?

可能的原因只能是扩容时机不好把握?

HashMap的扩容机制是键值对size超过阈值后,数组长度扩充至之前的两倍,然后将原本下标的全部链表迁移(这个迁移的过程会倒序链表,也可能分散链表中的数据,以缩短链表的长度)

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry e : table) {
        while(null != e) {
            Entry next = e.next;
            ...
            // 同一个元素转移后的下标有两种情况。
            // 1 与原来相同 2 在原来下标基础上加原数组长度
            int i = indexFor(e.hash, newCapacity);

            // 这样遍历头插后,链表的顺序与之前相反
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

了解HashMap源码的朋友可能都知道,这个扩容和迁移的代码在高并发时并不是线程安全的。可能出现循环链表,以至于get时陷入死循环。

也许正因如此,需要将链表插入和扩容的代码从之前的循环中独立出来。并采用头插的方式,尽量再循环一次链表。

最后

以上都是我在阅读HashMap源码后产生疑问,独立思考,自我解答的过程。

可能是很少有人产生跟我相似的疑问,所以我在网上也没能查找到准确的资料和答案。

所以以上全是自己的推断和猜测。毕竟HashMap源码不论是在数据结构还是算法思想层面都是非常优雅的。别人这么设计肯定是有原因的。哈哈

你可能感兴趣的:(IHAVEAQUESTION - JDK1.7 HashMap 链表头插疑问?)