【Java】Java8 HashMap 源码阅读

发现HashMap的源码和自己原本看到的文档不同,所以决定看看到底Java是如何实现HashMap的。

本文所使用的环境为
在这里插入图片描述
利用Idea提供的功能直接跳转到HashMap.put();的源码页面

        HashMap map = new HashMap<>();
        map.put("1", "1");

在这里插入图片描述
【Java】Java8 HashMap 源码阅读_第1张图片
接着往下看,如果说这个HashMap第一次调用了put函数,会有一个resize的操作来初始化实际上存储对象的Node数组。
【Java】Java8 HashMap 源码阅读_第2张图片
【Java】Java8 HashMap 源码阅读_第3张图片
按照代码给的注释来说。这个resize函数,会在初始化的时候新建一个存储对象的数组(16的容量),也用在HashMap扩容的时候,会以原容量两倍的规模进行扩容,原来的Key要么保持原位置不变,要么在扩容后的数组里移动2的幂次的位移量(这一点说法有些迷惑)。

        Node[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
            Node[] newTab = (Node[])new Node[newCap];
        table = newTab;
        ```
在函数的一开头,会对老的存储结构做一个检查,看看到底是要初始化一个新的存储结构,还是对老的存储结构容量达到了预定值而需要扩容。放弃边界条件,粗暴一些理解的话,就是全新的HashMap会按照16的规模进行数组的新建,达到预定值 threshold时, 会按照老容量 x2 进行扩容。

在扩容之后,老数据自然不能不要,所以需要把老数据迁移到新的扩容过的数组中,源码中的做法就是走一个For循环,一个一个搬运。
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node loHead = null, loTail = null;
                    Node hiHead = null, hiTail = null;
                    Node next;
                    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;
                    }
                }
            }
        }
    }
    return newTab;
    ```

可以看到在搬迁单个bucket,或者说数组中的一项的时候,做一个 e.next == null 的检查,这个就是我们常说的HashMap中的碰撞策略是用链表来实现的。当然如果经常发生碰撞的话,我们也知道HashMap的复杂度将退化为O(n),所以Java8中会对同一个bucket中,如果链表长度大于等于7的时候,会将其转化为树结构,进行优化,这一点会体现在新的键值对加入到HashMap时做检查。

在搬迁这样的链表时,HashMap会检查这个键值对Key的hash值(非hashCode,而是HashMap专用的一套Hash算法),如果说他和老容量与计算不为0的话,就会移动老容量的距离。

大概举个例子,就是这样昂
【Java】Java8 HashMap 源码阅读_第4张图片

老数据容量为16,oldCap = 16,当 j = 1 时,遇上了链表,此时会逐一遍历链表。当链表节点的hash值 和 oldCap(16)与计算等于0的时候,比如说 例子中的第一个节点 hash值为1,这个节点的hash并不代表他在数组中的位置,数组中的位置是用该hash值和 oldCap-1 与计算算出来的,所以1 & 16 = 0 ,他的位置没有改变,而17&16得出的结果为16,这个节点就被转移到了 1 + 16 = 17 的数组位置上去了。

这样的目的很容易看出来是为了优化链表结构,防止其推到O(n)的复杂度。

值得注意的时候,这个结果如果已经被转化为了树结构的话,则是利用树节点的插入方法。

HashMap的resize()方法就这么多。话题转回putVal方法。

 	    Node[] tab; Node p; int n, i;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            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;
            }
        }

对于Hashmap的put()方法来说,会先对Key做一个哈希化,然后再与当前存储容量-1做一个与运算,最后得出的结果才是这个键值对的存储位置。如果说这个位置上还没有值,那么直接将他放在这个位置。如果有了值,那么就是发生了hash碰撞。在常规的Hash方法中会再做一个hash直到没有碰撞位置。但是HashMap则是使用了包容的态度,将这些键值对都放在一个位置上。当然,如果两对键值对,Key的Hash值一样,内存位置一样,或者内存存储值一样,那么我们可以认为他们就是一样的,这个时候会直接替换,这么一想挺可怕的,如果这个位置上是一个链表的话,整个链表就没有了。(就像一样食物看起来像汉堡,尝起来像汉堡,闻起来像汉堡,那他一定是老八蜜汁小汉堡)
//(另注:可能这里就会有多线程错误,如果一个键值对在链表里插入,而另一个线程发生这样的替换)(待验证)

                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);

代码中这一样,就是在做链表到树结构的转换。

在一个我自己觉得有意思的就是remove操作,或者说如果你在别的地方另外建了一个Key,他怎么定位到之前你put到HashMap里的。我们都知道如果你要将一个自建类作为HashMap的话,你一定要新写一个hashCode()的方法,原因就是找到具体位置是和类的hashCode()方法有关系的。

    /**
     * Removes the mapping for the specified key from this map if present.
     *
     * @param  key key whose mapping is to be removed from the map
     * @return the previous value associated with key, or
     *         null if there was no mapping for key.
     *         (A null return can also indicate that the map
     *         previously associated null with key.)
     */
    public V remove(Object key) {
        Node e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    ```

remove()方法实际判断位置和put()一样有两个,hash(key)和key。
final Node removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node[] tab; Node p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode)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)))) {
            if (node instanceof TreeNode)
                ((TreeNode)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;
}

顺着代码读下来,基本逻辑和putVal一直,如果数组中某一个位置的起始节点就已经是目标键值对了,就直接让他指向第二个节点,即使他是null。如果不是的话,就会遍历查找,找到之后,再做remove去除操作。所以说,如果不重写hashCode方法的话,有两种可能,一种是把别的位置上的键值对去除了,另一种就是定位到了一个空的位置上,当然结果是一样的,你的remove失败了。

比较好奇的是,当数组元素减少,树结构居然没有变链表结构的代码。

你可能感兴趣的:(Java)