掌握 HashMap 看这一篇文章就够了(二)

简介: 最近几天,一直在学习 HashMap 的底层实现,发现关于 HashMap 实现的博客文章还是很多的,对比了一些,都没有一个很全面的文章来做总结,本篇文章也断断续续结合源码写了一下,如果有理解不当之处,欢迎指正!

3.3、resize 扩容过程

在说 jdk1.8 的 HashMap 动态扩容之前,我们先来了解一下 jdk1.7 的 HashMap 扩容实现,因为 jdk1.8 代码实现比 Java1.7 复杂了不止一倍,主要是 Java1.8 引入了红黑树设计,但是实现思想大同小异!

3.3.1、jdk1.7 的扩容实现源码部分

10.jpg

源码部分

/**
  * JDK1.7扩容方法
  * 传入新的容量
  */
void resize(int newCapacity) {
    //引用扩容前的Entry数组
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
  //扩容前的数组大小如果已经达到最大(2^30)了
    if (oldCapacity == MAXIMUM_CAPACITY) {
    //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
        threshold = Integer.MAX_VALUE;
        return;
    }
  //初始化一个新的Entry数组
    Entry[] newTable = new Entry[newCapacity];
  //将数据转移到新的Entry数组里,这里包含最重要的重新定位
    transfer(newTable);
  //HashMap的table属性引用新的Entry数组
    table = newTable;
    threshold = (int) (newCapacity * loadFactor);//修改阈值
}

transfer 复制数组方法,源码部分:

//遍历每个元素,按新的容量进行rehash,放到新的数组上
void transfer(Entry[] newTable) {
  //src引用了旧的Entry数组
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
    //遍历旧的Entry数组
        Entry<K, V> e = src[j];
    //取得旧Entry数组的每个元素
        if (e != null) {
      //释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
            src[j] = null;
            do {
                Entry<K, V> next = e.next;
        //重新计算每个元素在数组中的位置
        //实现逻辑,也是上文那个取模运算方法
                int i = indexFor(e.hash, newCapacity);
        //标记数组
                e.next = newTable[i];
        //将元素放在数组上
                newTable[i] = e;
        //访问下一个Entry链上的元素,循环遍历
                e = next;
            } while (e != null);
        }
    }
}

jdk1.7 扩容总结: newTable[i]的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话),这一点和 Jdk1.8 有区别。在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

3.3.2、jdk1.8 的扩容实现

11.jpg

源码如下

final Node<K,V>[] resize() {
    //引用扩容前的node数组
        Node<K,V>[] oldTab = table;
    //旧的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧的阈值
        int oldThr = threshold;
    //新的容量、阈值初始化为0
        int newCap, newThr = 0;
        if (oldCap > 0) {
        //如果旧容量已经超过最大容量,让阈值也等于最大容量,以后不再扩容
                threshold = Integer.MAX_VALUE;
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
      // 没超过最大值,就扩充为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
        //如果旧容量翻倍没有超过最大值,且旧容量不小于初始化容量16,则翻倍
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
      //初始化容量设置为阈值
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
      //0的时候使用默认值初始化
            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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //创建新的数组,并引用
        table = newTab;
    //如果老的数组有数据,也就是是扩容而不是初始化,才执行下面的代码,否则初始化的到这里就可以结束了
        if (oldTab != null) {
        //轮询老数组所有数据
            for (int j = 0; j < oldCap; ++j) {
        //以一个新的节点引用当前节点,然后释放原来的节点的引用
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
          //如果e没有next节点,证明这个节点上没有hash冲突,则直接把e的引用给到新的数组位置上
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
            //!!!如果是红黑树,则进行分裂
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else {
              // 链表优化重hash的代码块
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
            //从这条链表上第一个元素开始轮询,如果当前元素新增的bit是0,则放在当前这条链表上,如果是1,则放在"j+oldcap"这个位置上,生成“低位”和“高位”两个链表
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                  //元素是不断的加到尾部的,不会像1.7里面一样会倒序
                                    loTail.next = e;
                //新增的元素永远是尾元素
                                loTail = e;
                            }
                            else {
                //高位的链表与低位的链表处理逻辑一样,不断的把元素加到链表尾部
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
            //低位链表放到j这个索引的位置上
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
            //高位链表放到(j+oldCap)这个索引的位置上
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
}

1.7 与 1.8 处理逻辑大同小异,区别主要还是在树节点的分裂((TreeNode<K,V>)e).split()这个方法上

/**
 * 红黑树分裂方法
 */
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
      //当前这个节点的引用,即这个索引上的树的根节点
            TreeNode<K,V> b = this;
            // Relink into lo and hi lists, preserving order
            TreeNode<K,V> loHead = null, loTail = null;
            TreeNode<K,V> hiHead = null, hiTail = null;
      //高位低位的初始树节点个数都设成0
            int lc = 0, hc = 0;
            for (TreeNode<K,V> e = b, next; e != null; e = next) {
                next = (TreeNode<K,V>)e.next;
                e.next = null;
        //bit=oldcap,这里判断新bit位是0还是1,如果是0就放在低位树上,如果是1就放在高位树上,这里先是一个双向链表
                if ((e.hash & bit) == 0) {
                    if ((e.prev = loTail) == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                    ++lc;
                }
                else {
                    if ((e.prev = hiTail) == null)
                        hiHead = e;
                    else
                        hiTail.next = e;
                    hiTail = e;
                    ++hc;
                }
            }
            if (loHead != null) {
                if (lc <= UNTREEIFY_THRESHOLD)
          //!!!如果低位的链表长度小于阈值6,则把树变成链表,并放到新数组中j索引位置
                    tab[index] = loHead.untreeify(map);
                else {
                    tab[index] = loHead;
          //高位不为空,进行红黑树转换
                    if (hiHead != null) // (else is already treeified)
                        loHead.treeify(tab);
                }
            }
            if (hiHead != null) {
                if (hc <= UNTREEIFY_THRESHOLD)
                    tab[index + bit] = hiHead.untreeify(map);
                else {
                    tab[index + bit] = hiHead;
                    if (loHead != null)
                        hiHead.treeify(tab);
                }
            }
}

untreeify方法,将树转变为单向链表

/**
 * 将树转变为单向链表
 */
final Node<K,V> untreeify(HashMap<K,V> map) {
            Node<K,V> hd = null, tl = null;
            for (Node<K,V> q = this; q != null; q = q.next) {
                Node<K,V> p = map.replacementNode(q, null);
                if (tl == null)
                    hd = p;
                else
                    tl.next = p;
                tl = p;
            }
            return hd;
}

treeify方法,将链表转换为红黑树,会根据红黑树特性进行颜色转换、左旋、右旋等

/**
 * 链表转换为红黑树,会根据红黑树特性进行颜色转换、左旋、右旋等
 */
final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;
            for (TreeNode<K,V> x = this, next; x != null; x = next) {
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;
                if (root == null) {
                    x.parent = null;
                    x.red = false;
                    root = x;
                }
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    for (TreeNode<K,V> p = root;;) {
                        int dir, ph;
                        K pk = p.key;
                        if ((ph = p.hash) > h)
                            dir = -1;
                        else if (ph < h)
                            dir = 1;
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);
                        TreeNode<K,V> xp = p;
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            x.parent = xp;
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
              //进行左旋、右旋调整
                            root = balanceInsertion(root, x);
                            break;
                        }
                    }
                }
            }
            moveRootToFront(tab, root);
}

jdk1.8 在进行重新扩容之后,会重新计算 hash 值,因为 n 变为 2 倍,假设初始 tableSize = 4 要扩容到 8 来说就是 0100 到 1000 的变化(左移一位就是 2 倍),在扩容中只用判断原来的 hash 值与左移动的一位(newtable 的值)按位与操作是 0 或 1 就行,0 的话索引就不变,1 的话索引变成原索引 + oldCap;

其实现如下流程图所示:

12.jpg

可以看见,因为 hash 值本来就是随机性的,所以 hash 按位与上 newTable 得到的 0(扩容前的索引位置)和 1(扩容前索引位置加上扩容前数组长度的数值索引处)就是随机的,所以扩容的过程就能把之前哈希冲突的元素再随机的分布到不同的索引去,这算是 JDK1.8 的一个优化点。

此外,JDK1.7 中 rehash 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8 不会倒置。

同时,由于 JDK1.7 中发生哈希冲突时仅仅采用了链表结构存储冲突元素,所以扩容时仅仅是重新计算其存储位置而已。而 JDK1.8 中为了性能在同一索引处发生哈希冲突到一定程度时,链表结构会转换为红黑数结构存储冲突元素,故在扩容时如果当前索引中元素结构是红黑树且元素个数小于链表还原阈值时就会把树形结构缩小或直接还原为链表结构(其实现就是上面代码片段中的 split() 方法)。

3.4、get 方法获取参数值

get(Object key)方法根据指定的 key 值返回对应的 value,getNode(hash(key), key))得到相应的 Node 对象 e,然后返回 e.value。因此 getNode()是算法的核心。

141.jpg

get 方法源码部分:

/**
  * JDK1.8 get方法
  * 通过key获取参数值
  */
public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
}

通过 hash 值和 key 获取节点 Node 方法,源码部分:

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
      //1、判断第一个元素是否与key匹配
            if (first.hash == hash &&
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
        //2、判断链表是否红黑树结构
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
        //3、如果不是红黑树结构,直接循环判断
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
}

在红黑树中找到指定 k 的 TreeNode,源码部分:

/**
  * 这里面情况分很多中,主要是因为考虑了hash相同但是key值不同的情况,查找的最核心还是落在key值上
  */
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
            TreeNode<K,V> p = this;
            do {
                int ph, dir; K pk;
                TreeNode<K,V> pl = p.left, pr = p.right, q;
        //判断要查询元素的hash是否在树的左边
                if ((ph = p.hash) > h)
                    p = pl;
        //判断要查询元素的hash是否在树的右边
                else if (ph < h)
                    p = pr;
        //查询元素的hash与当前树节点hash相同情况
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                    return p;
        //上面的三步都是正常的在二叉查找树中寻找对象的方法
        //如果hash相等,但是内容却不相等
                else if (pl == null)
                    p = pr;
                else if (pr == null)
                    p = pl;
         //如果可以根据compareTo进行比较的话就根据compareTo进行比较
                else if ((kc != null ||
                          (kc = comparableClassFor(k)) != null) &&
                         (dir = compareComparables(kc, k, pk)) != 0)
                    p = (dir < 0) ? pl : pr;
        //根据compareTo的结果在右孩子上继续查询
                else if ((q = pr.find(h, k, kc)) != null)
                    return q;
        //根据compareTo的结果在左孩子上继续查询
                else
                    p = pl;
            } while (p != null);
            return null;
}

get 方法,首先通过 hash()函数得到对应数组下标,然后依次判断。

  • 1、判断第一个元素与 key 是否匹配,如果匹配就返回参数值;
  • 2、判断链表是否红黑树,如果是红黑树,就进入红黑树方法获取参数值;
  • 3、如果不是红黑树结构,直接循环判断,直到获取参数为止;

3.5、remove 删除元素

remove(Object key)的作用是删除 key 值对应的 Node,该方法的具体逻辑是在removeNode(hash(key), key, null, false, true)里实现的。

15.jpg

remove 方法,源码部分:

/**
  * JDK1.8 remove方法
  * 通过key移除对象
  */
public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
}

通过 key 移除 Node 节点方法,源码部分:

/**
  * 通过key移除Node节点
  */
final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
    //1、判断要删除的元素,是否存在
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
      //2、判断第一个元素是不是我们要找的元素
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
        //3、判断当前冲突链表是否红黑树结构
                if (p instanceof TreeNode)
                    node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                else {
          //4、循环在链表中找到需要删除的元素
                    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)))) {
        //5、如果当前冲突链表结构是红黑树,执行红黑树删除方法
                if (node instanceof TreeNode)
                    ((TreeNode<K,V>)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;
}

removeTreeNode 移除红黑树节点方法,源码部分:

final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
                                  boolean movable) {
            int n;
            if (tab == null || (n = tab.length) == 0)
                return;
            int index = (n - 1) & hash;
            TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl;
            TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
            if (pred == null)
                tab[index] = first = succ;
            else
                pred.next = succ;
            if (succ != null)
                succ.prev = pred;
            if (first == null)
                return;
            if (root.parent != null)
                root = root.root();
            if (root == null || root.right == null ||
                (rl = root.left) == null || rl.left == null) {
                tab[index] = first.untreeify(map);  // too small
                return;
            }
            TreeNode<K,V> p = this, pl = left, pr = right, replacement;
            if (pl != null && pr != null) {
                TreeNode<K,V> s = pr, sl;
                while ((sl = s.left) != null) // find successor
                    s = sl;
                boolean c = s.red; s.red = p.red; p.red = c; // swap colors
                TreeNode<K,V> sr = s.right;
                TreeNode<K,V> pp = p.parent;
                if (s == pr) { // p was s's direct parent
                    p.parent = s;
                    s.right = p;
                }
                else {
                    TreeNode<K,V> sp = s.parent;
                    if ((p.parent = sp) != null) {
                        if (s == sp.left)
                            sp.left = p;
                        else
                            sp.right = p;
                    }
                    if ((s.right = pr) != null)
                        pr.parent = s;
                }
                p.left = null;
                if ((p.right = sr) != null)
                    sr.parent = p;
                if ((s.left = pl) != null)
                    pl.parent = s;
                if ((s.parent = pp) == null)
                    root = s;
                else if (p == pp.left)
                    pp.left = s;
                else
                    pp.right = s;
                if (sr != null)
                    replacement = sr;
                else
                    replacement = p;
            }
            else if (pl != null)
                replacement = pl;
            else if (pr != null)
                replacement = pr;
            else
                replacement = p;
            if (replacement != p) {
                TreeNode<K,V> pp = replacement.parent = p.parent;
                if (pp == null)
                    root = replacement;
                else if (p == pp.left)
                    pp.left = replacement;
                else
                    pp.right = replacement;
                p.left = p.right = p.parent = null;
            }
      //判断是否需要进行红黑树结构调整
            TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement);
            if (replacement == p) {  // detach
                TreeNode<K,V> pp = p.parent;
                p.parent = null;
                if (pp != null) {
                    if (p == pp.left)
                        pp.left = null;
                    else if (p == pp.right)
                        pp.right = null;
                }
            }
            if (movable)
                moveRootToFront(tab, r);
}

jdk1.8 的删除逻辑实现比较复杂,相比 jdk1.7 而言,多了红黑树节点删除和调整:

  • 1、默认判断链表第一个元素是否是要删除的元素;
  • 2、如果第一个不是,就继续判断当前冲突链表是否是红黑树,如果是,就进入红黑树里面去找;
  • 3、如果当前冲突链表不是红黑树,就直接在链表中循环判断,直到找到为止;
  • 4、将找到的节点,删除掉,如果是红黑树结构,会进行颜色转换、左旋、右旋调整,直到满足红黑树特性为止;

04、总结

1、如果 key 是一个对象,记得在对象实体类里面,要重写 equals 和 hashCode 方法,不然在查询的时候,无法通过对象 key 来获取参数值!

2、相比 JDK1.7,JDK1.8 引入红黑树设计,当链表长度大于 8 的时候,链表会转化为红黑树结构,发生冲突的链表如果很长,红黑树的实现很大程度优化了 HashMap 的性能,使查询效率比 JDK1.7 要快一倍!

3、对于大数组的情况,可以提前给 Map 初始化一个容量,避免在插入的时候,频繁的扩容,因为扩容本身就比较消耗性能!

05、参考资料

1、

美团技术团队 - Java 8系列之重新认识HashMap: https://zhuanlan.zhihu.com/p/21673805

2、

简书 - JDK1.8红黑树实现分析-此鱼不得水: https://www.jianshu.com/p/34b6878ae6de

3、

简书 - JJDK 1.8 中 HashMap 扩容: https://www.jianshu.com/p/bdfd5f98cc31

4、

Java HashMap 基础面试常见问题: https://www.rabbitwfly.com/articles/2019/04/23/1556021848567.html


相关文章
|
存储 Java
每日一道面试题之谈一谈HashMap和HashSet的区别
每日一道面试题之谈一谈HashMap和HashSet的区别
|
4月前
|
安全 Java
【Java集合类面试十五】、说一说HashMap和HashTable的区别
HashMap和Hashtable的主要区别在于Hashtable是线程安全的,不允许null键和值,而HashMap是非线程安全的,允许null键和值。
|
4月前
|
安全 Java
【Java集合类面试十六】、HashMap与ConcurrentHashMap有什么区别?
HashMap是非线程安全的,而ConcurrentHashMap通过减少锁粒度来提高并发性能,检索操作无需锁,从而提供更好的线程安全性和性能。
|
7月前
|
存储 Java Serverless
【面试问题】如何设计一个 HashMap?
【1月更文挑战第27天】【面试问题】如何设计一个 HashMap?
|
7月前
|
安全
搞懂HashTable, HashMap, ConcurrentHashMap 的区别,看着一篇就足够了!!!
搞懂HashTable, HashMap, ConcurrentHashMap 的区别,看着一篇就足够了!!!
138 0
|
算法 Java
Java Map的Hash算法究竟干了什么?
Java Map的Hash算法究竟干了什么?
116 0
|
安全 Java
HashMap源码学习
线程上:HashMap是线程不安全的,而HashTable是安全的。key、value的支持:HashMap的key、balue可以为空,而HashTable是key、value不可以为空的。底层数据结构:HashMap采用数组+链表+红黑树,当链表的长度>=8的时候会考虑是否转成红黑树,而HashTable则没有。初始化容量上:HashTable的初始化容量是11,同时扩容变为原来的2n+1,HashMap的初始化容量是16,同时扩容扩容成原来的2倍。而当给定初始化容量时,HashTable是直接给定初始化容量,而HashMap是将给定的初始化容量变成2的次幂。
73 0
HashMap源码学习
|
算法 Java
【HashMap】由tableSizeFor想到的一个算法
【HashMap】由tableSizeFor想到的一个算法
137 0
|
移动开发 Java 索引
【面试:基础篇08:HashMap全总结】
【面试:基础篇08:HashMap全总结】
219 0
HashMap 使用的时候指定容量?你真的用明白了吗?(值得一阅)
HashMap 使用的时候指定容量?你真的用明白了吗?(值得一阅)
294 0
HashMap 使用的时候指定容量?你真的用明白了吗?(值得一阅)